diff --git a/Cargo.lock b/Cargo.lock index 5cf21cd..5a2f78e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,7 +208,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -393,25 +393,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -440,8 +421,6 @@ dependencies = [ "lock_api", "once_cell", "parking_lot_core", - "rayon", - "serde", ] [[package]] @@ -474,14 +453,13 @@ name = "discord-bot" version = "0.1.0" dependencies = [ "clap", - "dashmap", + "indexmap", + "itertools 0.14.0", "opendal", "postcard", "rand 0.9.0", "secrecy", "serde", - "sharded-slab", - "slab", "snafu", "tokio", "tracing", @@ -491,6 +469,8 @@ dependencies = [ "twilight-http", "twilight-model", "twilight-util", + "ulid", + "uuid", ] [[package]] @@ -1136,6 +1116,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1233,6 +1222,15 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1599,26 +1597,6 @@ dependencies = [ "getrandom 0.3.2", ] -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.11" @@ -1636,8 +1614,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1648,9 +1635,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2285,10 +2278,14 @@ version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -2413,6 +2410,18 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.0", + "serde", + "uuid", + "web-time", +] + [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/discord-bot/Cargo.toml b/discord-bot/Cargo.toml index f32f316..e111825 100644 --- a/discord-bot/Cargo.toml +++ b/discord-bot/Cargo.toml @@ -5,14 +5,13 @@ edition = "2024" [dependencies] clap = { version = "4.5.36", features = ["derive", "env"] } -dashmap = { version = "6.1.0", features = ["rayon", "serde"] } +indexmap = "2.9.0" +itertools = "0.14.0" opendal = { version = "0.53.0", features = ["services-fs"] } postcard = { version = "1.1.1", features = ["use-std"] } rand = "0.9.0" secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } -sharded-slab = "0.1.7" -slab = "0.4.9" snafu = "0.8.5" tokio = { version = "1.44.2", features = [ "macros", @@ -21,7 +20,7 @@ tokio = { version = "1.44.2", features = [ "time", ] } tracing = "0.1.41" -tracing-subscriber = "0.3.19" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } twilight-cache-inmemory = "0.16.0" twilight-gateway = { version = "0.16.0", default-features = false, features = [ "rustls-aws_lc_rs", @@ -35,3 +34,5 @@ twilight-http = { version = "0.16.0", default-features = false, features = [ ] } twilight-model = "0.16.0" twilight-util = { version = "0.16.0", features = ["builder"] } +ulid = { version = "1.2.1", features = ["serde", "uuid"] } +uuid = { version = "1.16.0", features = ["v7", "serde"] } diff --git a/discord-bot/src/discord_formatting.rs b/discord-bot/src/discord_formatting.rs new file mode 100644 index 0000000..ecd777a --- /dev/null +++ b/discord-bot/src/discord_formatting.rs @@ -0,0 +1,821 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + iter::repeat, + time::Duration, +}; + +use itertools::Itertools; +use twilight_model::{ + channel::message::Embed, + id::{ + Id, + marker::{CommandMarker, UserMarker}, + }, +}; +use twilight_util::builder::embed::{EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder}; + +use crate::game::GameTopic; + +const NO_LOBBY_FOR_PRESS_HEADER: &str = "No Lobby for That"; +const NO_LOBBY_FOR_PRESS_DESCRIPTION: &str = "You tried to press a button for a game but there isn't a running lobby for it.\nOr am I malfunctioning? Can you please let J Navith know about this bug if so?"; + +const NO_LOBBY_FOR_RESPONSE_HEADER: &str = "No Lobby for That"; +const NO_LOBBY_FOR_RESPONSE_DESCRIPTION: &str = "You tried to submit a response but there isn't a running lobby for that.\nOr am I malfunctioning? Can you please let J Navith know about this bug if so?"; + +const UNRECOGNIZED_BUTTON_HEADER: &str = "Unrecognized Button"; +const UNRECOGNIZED_BUTTON_DESCRIPTION: &str = "I don't recognize the button you pressed. Is it from a previous game or turn? Only buttons from this game and this turn are expected to work.\nOr am I malfunctioning? Can you please let J Navith know about this bug?"; + +const CREATED_LOBBY_TITLE: &str = "You've created a lobby!"; +const CREATED_LOBBY_DESCRIPTION: &str = "Here are your controls to run the lobby. Remember that you still have to join with the public message below!"; + +const MAIN_LOBBY_HEADER: &str = "Impersonation Game Lobby"; + +const GAME_DESCRIPTION: &str = "Impersonation Game is a Fibbage: Enough About You -esque deduction game. Each turn, a subject player and topic are chosen and everyone writes a response to try to convince the other players that the subject wrote it! You get points for guessing the response the subject actually wrote, and you get points for other players choosing your impersonation! When you're the subject, you get points for other players guessing your authentic response! Each turn, you can also award points to the *one* response you enjoy most (and you get points too for your generosity)!"; + +const BASIC_STRATEGY_HEADER: &str = "Basic Strategy"; +const BASIC_STRATEGY: &str = "This game is about much more than information, it's about presentation: grammar, punctuation, tone, word choice, and more. Try your best to sound like yourself and not like someone pretending to be you. Likewise, when impersonating someone else, do the same but for them."; + +const EXTENDED_STRATEGY_HEADER: &str = "Possible Writing Ideas"; +const EXTENDED_STRATEGY: &str = "Consider writing information about yourself that isn't already known but that people think lines up with your personality traits. Everyone's allowed to search the internet during the game, so maybe write some jargon that other players will see as making sense."; + +const LOBBY_RUNNER_HEADER: &str = "Lobby Runner"; +const PLAYERS_IN_HEADER: &str = "Players In"; + +const DISCLAIMER: &str = "This game is unfinished. It'll be buggy and unpolished."; +const DISCLAIMER_END: &str = + "This game is unfinished. I hope it was still fun to playtest and shows potential!"; + +const AUTOCLOSING_LOBBY_HEADER: &str = "Closing Without Starting"; +const AUTOCLOSING_LOBBY_DESCRIPTION: &str = "The lobby wasn't started in time (it's a long window), so it's closing now without starting even if there are enough players waiting.\nJust start another if you all still want to play."; + +const SUCCESSFUL_ABORT_HEADER: &str = "Lobby Aborted"; +const SUCCESSFUL_ABORT_DESCRIPTION: &str = + "This lobby has been closed.\nJust start another when enough people want to play."; + +const GAME_ALREADY_RUNNING_HEADER: &str = "Game Already Running"; +const GAME_ALREADY_RUNNING_DESCRIPTION: &str = + "The game is already running; you don't need to start it."; + +const ALREADY_EXISTING_GAME_HEADER: &str = "Already a Lobby Here"; +const ALREADY_EXISTING_GAME_DESCRIPTION: &str = "You can't make a lobby in this channel while one already exists. Join that one or have it aborted."; + +const INSUFFICIENT_PLAYERS_HEADER: &str = "Not Enough Players"; +const INSUFFICIENT_PLAYERS_DESCRIPTION: &str = "There needs to be at least 3 players in the lobby for the game to work (and be allowed to start).\nGet more people to join before you may click the Start button."; + +const HELLO_TO_GAME_HEADER: &str = "You Joined!"; +const HELLO_TO_GAME_DESCRIPTION: &str = + "Welcome to the game! Sit tight for the lobby runner to start the game..."; + +const GOODBYE_FROM_GAME_HEADER: &str = "You Left"; +const GOODBYE_FROM_GAME_DESCRIPTION: &str = "You're no longer in the game."; + +const ALREADY_IN_GAME_HEADER: &str = "Already In"; +const ALREADY_IN_GAME_DESCRIPTION: &str = "Don't worry, you're already in the game!"; + +const ALREADY_NOT_IN_GAME_HEADER: &str = "Already Out"; +const ALREADY_NOT_IN_GAME_DESCRIPTION: &str = + "You're already not in the game — and we're missing you for it."; + +const MAY_NOT_ABORT_HEADER: &str = "Too Late to Abort"; +const MAY_NOT_ABORT_DESCRIPTION: &str = + "The game's just gotta run its course; you can't abort it now. Sorry."; + +const NEW_TURN_TITLE: &str = "New Turn"; + +const HOW_TO_RESPOND_HEADER: &str = "How to Respond"; + +const REMAINING_TIME_HEADER: &str = "Time Remaining"; + +const RESPONSE_RECORDED_HEADER: &str = "Response Recorded"; +const RESPONSE_RECORDED_DESCRIPTION: &str = "I've got your response. Hang tight while everyone submits theirs.\n(Oh, and you can still change your response while waiting!)"; + +const ALL_RESPONSES_IN_HEADER: &str = "All Responses In"; +const ALL_RESPONSES_IN_DESCRIPTION: &str = + "Amazing! Everyone submitted a response before time ran out!"; + +const TIME_UP_NOT_ALL_RESPONSES_IN_HEADER: &str = "Time to Respond is Up"; +const TIME_UP_NOT_ALL_RESPONSES_IN_DESCRIPTION: &str = + "Not everyone submitted a response in time. That's too bad."; + +const NO_RESPONSES_HEADER: &str = "No Responses?!"; +const NO_RESPONSES_DESCRIPTION: &str = "Oh, wait, really? Nobody submitted anything?\nWell, sorry, but I we can't have a guessing and awarding phase without any responses, so let's move on to the next turn."; + +const GUESSING_PHASE_PREFIX_TITLE: &str = "Guessing & Awarding Phase"; + +const GUESSING_PHASE_POSTFIX_DESCRIPTION: &str = "^ Those were all the responses.\nClick the 🔘 Guess button for the response you think is authentic (truly from the subject).\nClick the 🏆 Award button for the one you feel earns it most (you get an equal amount of points for it).\nUntil time's up or all players have made a guess and given an award (whichever comes first), you can freely change your picks!"; + +const GUESS_RECORDED_HEADER: &str = "Guess Recorded"; +const GUESS_RECORDED_DESCRIPTION: &str = "I've got your guess; you'll get points if you guessed the authentic response. Hang tight while everyone submits their guess and award this turn...\n(Oh, and you can still change your guess or award while waiting!)"; + +const AWARD_RECORDED_HEADER: &str = "Award Given"; +const AWARD_RECORDED_DESCRIPTION: &str = "Nice, thanks for giving your favorite response this turn an award! Hang tight while everyone submits their guess and award this turn...\n(Oh, and you can still change your guess or award while waiting!)"; + +const NO_SELF_AWARDING_HEADER: &str = "Award Someone Else"; +const NO_SELF_AWARDING_DESCRIPTION: &str = "Nice try, but you can't award your own your response! Pick one from someone else — you still get points yourself!"; + +const NO_SELF_GUESSING_HEADER: &str = "Guess Someone Else"; +const NO_SELF_GUESSING_DESCRIPTION: &str = "You can't guess your own response as the authentic one because you know it's not. Try guessing one of the other responses."; + +const NO_SUBJECT_GUESSING_HEADER: &str = "You're the Subject"; +const NO_SUBJECT_GUESSING_DESCRIPTION: &str = "You know you wrote the authentic response, so there's no reason to let you guess. You can still give an award though!"; + +const NOT_TIME_TO_RESPOND_HEADER: &str = "Can't Respond Now"; +const NOT_TIME_TO_RESPOND_DESCRIPTION: &str = "It's not the writing phase anymore (or yet). You can have your response back instead of it being lost to the ether."; + +const ALL_GUESSES_AND_AWARDS_IN_HEADER: &str = "All Guesses & Awards In"; +const ALL_GUESSES_AND_AWARDS_IN_DESCRIPTION: &str = + "Hurray! Everyone's guessed and awarded a response!"; + +const REJECTED_RESPONSE_HEADER: &str = "Your Submission"; + +const TIME_UP_NOT_ALL_GUESSES_OR_AWARDS_IN_HEADER: &str = "Guessing & Awarding Time Over"; +const TIME_UP_NOT_ALL_GUESSES_OR_AWARDS_IN_DESCRIPTION: &str = "Time ran out before everyone submitted a guess *and* award. It's their loss; let's just move on."; + +const NOT_TIME_TO_GUESS_HEADER: &str = "Can't Guess Now"; +const NOT_TIME_TO_GUESS_DESCRIPTION: &str = "It's not the guessing phase anymore (or yet). In fact, I'm surprised you've even encountered this error!"; + +const NOT_TIME_TO_AWARD_HEADER: &str = "Can't Award Now"; +const NOT_TIME_TO_AWARD_DESCRIPTION: &str = "It's not the guessing phase anymore (or yet), so you can't give out an award. In fact, I'm surprised you've even encountered this error!"; + +const AWARD_FROM_HEADER: &str = "Award from"; +const GUESSED_BY_HEADER: &str = "Guessed by"; +const FOUND_BY_HEADER: &str = "Found by"; +const WRITTEN_BY_HEADER: &str = "Written by"; + +const IMPERSONATION_TITLE: &str = "Impersonation!"; +const AUTHENTIC_TITLE: &str = "The Authentic Response!"; +const MISSED_TITLE: &str = "Missed!"; + +const COLOR_WRONG: u32 = 0xef4444; +const COLOR_MISSED: u32 = 0xeab308; +const COLOR_CORRECT: u32 = 0x10b981; + +const MIDGAME_LEADERBOARD_TITLE: &str = "Current Standings"; + +const PREFIX_ENDGAME_HEADER: &str = "Final Placements"; +const PREFIX_ENDGAME_DESCRIPTION: &str = + "Let's all see everyone's final score and place since this is the end of the game!"; + +const BRONZE_TITLE: &str = "Bronze"; +const SILVER_TITLE: &str = "Silver"; + +const BRONZE_COLOR: u32 = 0xAD8A56; +const SILVER_COLOR: u32 = 0xD7D7D7; +const GOLD_COLOR: u32 = 0xC9B037; + +const THANKS_FOR_PLAYING_HEADER: &str = "Thanks for Playing!"; + +fn format_names( + mut users: BTreeSet>, + names: &BTreeMap, String>, +) -> String { + match users.pop_last() { + Some(last) => { + let first_names = users + .into_iter() + .map(|user| names.get(&user).unwrap()) + .collect_vec(); + + let last_name = names.get(&last).unwrap(); + + if first_names.len() == 0 { + last_name.to_owned() + } else { + let first_names = itertools::join(first_names, ", "); + + format!("{first_names} & {last_name}") + } + } + None => "No one".into(), + } +} + +pub fn embed_for_no_lobby_for_press() -> Embed { + EmbedBuilder::new() + .title(NO_LOBBY_FOR_PRESS_HEADER) + .description(NO_LOBBY_FOR_PRESS_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_no_lobby_for_response(response: String) -> Embed { + EmbedBuilder::new() + .title(NO_LOBBY_FOR_RESPONSE_HEADER) + .description(NO_LOBBY_FOR_RESPONSE_DESCRIPTION) + .field(EmbedFieldBuilder::new(REJECTED_RESPONSE_HEADER, response).build()) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_unrecognized_button() -> Embed { + EmbedBuilder::new() + .title(UNRECOGNIZED_BUTTON_HEADER) + .description(UNRECOGNIZED_BUTTON_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_not_meeting_minimum_necessary_players() -> Embed { + EmbedBuilder::new() + .title(INSUFFICIENT_PLAYERS_HEADER) + .description(INSUFFICIENT_PLAYERS_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_private_lobby_controls() -> Embed { + EmbedBuilder::new() + .title(CREATED_LOBBY_TITLE) + .description(CREATED_LOBBY_DESCRIPTION) + .footer(EmbedFooterBuilder::new(DISCLAIMER)) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_main_lobby_message( + lobby_runner_id: Id, + players_in: impl Iterator>, + names: &BTreeMap, String>, +) -> Embed { + EmbedBuilder::new() + .title(MAIN_LOBBY_HEADER) + .description(GAME_DESCRIPTION) + .field(EmbedFieldBuilder::new(BASIC_STRATEGY_HEADER, BASIC_STRATEGY).build()) + .field(EmbedFieldBuilder::new(EXTENDED_STRATEGY_HEADER, EXTENDED_STRATEGY).build()) + .field(EmbedFieldBuilder::new( + LOBBY_RUNNER_HEADER, + names.get(&lobby_runner_id).unwrap(), + )) + .field( + EmbedFieldBuilder::new( + PLAYERS_IN_HEADER, + format_names(players_in.collect(), &names), + ) + .build(), + ) + .footer(EmbedFooterBuilder::new(DISCLAIMER)) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_autoclosing_lobby() -> Embed { + EmbedBuilder::new() + .title(AUTOCLOSING_LOBBY_HEADER) + .description(AUTOCLOSING_LOBBY_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_successful_abort() -> Embed { + EmbedBuilder::new() + .title(SUCCESSFUL_ABORT_HEADER) + .description(SUCCESSFUL_ABORT_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_already_existing_game() -> Embed { + EmbedBuilder::new() + .title(ALREADY_EXISTING_GAME_HEADER) + .description(ALREADY_EXISTING_GAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_game_already_running() -> Embed { + EmbedBuilder::new() + .title(GAME_ALREADY_RUNNING_HEADER) + .description(GAME_ALREADY_RUNNING_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_to_say_hello_into_game() -> Embed { + EmbedBuilder::new() + .title(HELLO_TO_GAME_HEADER) + .description(HELLO_TO_GAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_to_say_goodbye_from_game() -> Embed { + EmbedBuilder::new() + .title(GOODBYE_FROM_GAME_HEADER) + .description(GOODBYE_FROM_GAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_to_say_already_in_game() -> Embed { + EmbedBuilder::new() + .title(ALREADY_IN_GAME_HEADER) + .description(ALREADY_IN_GAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_to_say_already_not_in_game() -> Embed { + EmbedBuilder::new() + .title(ALREADY_NOT_IN_GAME_HEADER) + .description(ALREADY_NOT_IN_GAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_may_not_abort() -> Embed { + EmbedBuilder::new() + .title(MAY_NOT_ABORT_HEADER) + .description(MAY_NOT_ABORT_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +fn format_description_for_new_turn(subject_name: &str) -> String { + format!( + "{subject_name} will be the subject this turn.\nLet's see what the topic to write about is." + ) +} + +pub fn embed_for_new_turn_about_player_and_topic(subject_name: &str) -> Embed { + EmbedBuilder::new() + .title(NEW_TURN_TITLE) + .description(format_description_for_new_turn(subject_name)) + .validate() + .unwrap() + .build() +} + +fn format_title_for_writing_phase(subject_name: &str, game_topic: &GameTopic) -> String { + match game_topic { + GameTopic::Text(topic) => format!("{subject_name}: on the topic of {topic}"), + GameTopic::Player(other_player_name) => { + format!("{subject_name}: writing about fellow player {other_player_name}") + } + GameTopic::None => format!("{subject_name}... without a topic?!"), + } +} + +fn format_description_for_writing_phase(subject_name: &str, game_topic: &GameTopic) -> String { + match game_topic { + GameTopic::Text(topic) => format!( + "Try to sound like **{subject_name}** writing about **{topic}** at this very moment. And if you are that player, try to sound like yourself and not like someone pretending to be you!" + ), + GameTopic::Player(other_player_name) => { + format!( + "You'll all be writing a response about **{other_player_name}** that sounds like it was written by **{subject_name}**. Good luck!" + ) + } + GameTopic::None => format!( + "That's right... there's **no suggested topic** for {subject_name}'s turn. Anyone can write about anything! Still, try to sound like {subject_name}." + ), + } +} + +fn how_to_respond(respond_command_id: Id) -> String { + format!( + "Write your response with the `/respond` Discord slash command. There's only one field to fill in, called `text` — that's where your response goes.\nOr, you could click this: " + ) +} + +fn format_time_remaining(remaining: Duration) -> String { + let nearest_number_of_seconds = remaining.as_secs_f32().round() as u32; + + match nearest_number_of_seconds { + 0 => "🔔 Time's up!".into(), + 1 => "⏰ 1 second left!".into(), + few @ 2..=10 => format!("⏰ {few} seconds left!"), + many => format!("⏲️ {many} seconds left..."), + } +} + +pub fn embed_for_writing_responses_phase( + subject_name: &str, + topic: &GameTopic, + respond_command_id: Id, + time_remaining: Duration, +) -> Embed { + EmbedBuilder::new() + .title(format_title_for_writing_phase(subject_name, topic)) + .description(format_description_for_writing_phase(subject_name, topic)) + .field( + EmbedFieldBuilder::new(HOW_TO_RESPOND_HEADER, how_to_respond(respond_command_id)) + .build(), + ) + .field( + EmbedFieldBuilder::new(REMAINING_TIME_HEADER, format_time_remaining(time_remaining)) + .build(), + ) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_response_recorded() -> Embed { + EmbedBuilder::new() + .title(RESPONSE_RECORDED_HEADER) + .description(RESPONSE_RECORDED_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_all_responses_in() -> Embed { + EmbedBuilder::new() + .title(ALL_RESPONSES_IN_HEADER) + .description(ALL_RESPONSES_IN_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_time_up_not_all_responses_in() -> Embed { + EmbedBuilder::new() + .title(TIME_UP_NOT_ALL_RESPONSES_IN_HEADER) + .description(TIME_UP_NOT_ALL_RESPONSES_IN_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_no_responses() -> Embed { + EmbedBuilder::new() + .title(NO_RESPONSES_HEADER) + .description(NO_RESPONSES_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +fn format_description_for_prefix_guessing_phase( + subject_name: &str, + game_topic: &GameTopic, +) -> String { + const INTRODUCTORY_SENTENCE: &str = + "Thanks for writing a response everyone! Let's take a look at them."; + const ENDING_SENTENCE: &str = "And give an award to the response you like the most!"; + + match game_topic { + GameTopic::Text(topic) => format!( + "{INTRODUCTORY_SENTENCE}\nPick the one you think was actually **{subject_name}** writing about **{topic}**.\n{ENDING_SENTENCE}" + ), + GameTopic::Player(other_player_name) => { + format!( + "{INTRODUCTORY_SENTENCE}\nPick the one you think was actually **{subject_name}** writing about **{other_player_name}**.\n{ENDING_SENTENCE}" + ) + } + GameTopic::None => format!( + "{INTRODUCTORY_SENTENCE}\nPick the one you think was actually **{subject_name}** writing freehand.\n{ENDING_SENTENCE}" + ), + } +} + +pub fn embed_for_prefix_guessing_phase(subject_name: &str, topic: &GameTopic) -> Embed { + EmbedBuilder::new() + .title(GUESSING_PHASE_PREFIX_TITLE) + .description(format_description_for_prefix_guessing_phase( + subject_name, + topic, + )) + .validate() + .unwrap() + .build() +} + +fn format_title_for_postfix_guessing_phase(subject_name: &str, game_topic: &GameTopic) -> String { + match game_topic { + GameTopic::Text(topic) => format!("Which is {subject_name} writing about {topic}?"), + GameTopic::Player(other_player_name) => { + format!("Which is {subject_name} writing about {other_player_name}?") + } + GameTopic::None => format!("Which is {subject_name} writing without a topic?"), + } +} + +pub fn embed_for_postfix_guessing_phase( + subject_name: &str, + topic: &GameTopic, + time_remaining: Duration, +) -> Embed { + EmbedBuilder::new() + .title(format_title_for_postfix_guessing_phase(subject_name, topic)) + .description(GUESSING_PHASE_POSTFIX_DESCRIPTION) + .field( + EmbedFieldBuilder::new(REMAINING_TIME_HEADER, format_time_remaining(time_remaining)) + .build(), + ) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_guess_recorded() -> Embed { + EmbedBuilder::new() + .title(GUESS_RECORDED_HEADER) + .description(GUESS_RECORDED_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_award_recorded() -> Embed { + EmbedBuilder::new() + .title(AWARD_RECORDED_HEADER) + .description(AWARD_RECORDED_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_no_self_awarding() -> Embed { + EmbedBuilder::new() + .title(NO_SELF_AWARDING_HEADER) + .description(NO_SELF_AWARDING_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_no_self_guessing() -> Embed { + EmbedBuilder::new() + .title(NO_SELF_GUESSING_HEADER) + .description(NO_SELF_GUESSING_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_no_subject_guessing() -> Embed { + EmbedBuilder::new() + .title(NO_SUBJECT_GUESSING_HEADER) + .description(NO_SUBJECT_GUESSING_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_not_time_to_respond(response: String) -> Embed { + EmbedBuilder::new() + .title(NOT_TIME_TO_RESPOND_HEADER) + .description(NOT_TIME_TO_RESPOND_DESCRIPTION) + .field(EmbedFieldBuilder::new(REJECTED_RESPONSE_HEADER, response)) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_all_guesses_and_awards_in() -> Embed { + EmbedBuilder::new() + .title(ALL_GUESSES_AND_AWARDS_IN_HEADER) + .description(ALL_GUESSES_AND_AWARDS_IN_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_time_up_not_all_guesses_or_awards_in() -> Embed { + EmbedBuilder::new() + .title(TIME_UP_NOT_ALL_GUESSES_OR_AWARDS_IN_HEADER) + .description(TIME_UP_NOT_ALL_GUESSES_OR_AWARDS_IN_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_not_time_to_guess() -> Embed { + EmbedBuilder::new() + .title(NOT_TIME_TO_GUESS_HEADER) + .description(NOT_TIME_TO_GUESS_DESCRIPTION) + .validate() + .unwrap() + .build() +} +pub fn embed_for_not_time_to_award() -> Embed { + EmbedBuilder::new() + .title(NOT_TIME_TO_AWARD_HEADER) + .description(NOT_TIME_TO_AWARD_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_focused_response( + response: &str, + guessers: BTreeSet>, + awarders: BTreeSet>, + names: &BTreeMap, String>, +) -> Embed { + let guesser_names = format_names(guessers, names); + let awarder_names = format_names(awarders, names); + + EmbedBuilder::new() + .description(response) + .field(EmbedFieldBuilder::new(AWARD_FROM_HEADER, awarder_names).build()) + .field(EmbedFieldBuilder::new(FOUND_BY_HEADER, guesser_names).build()) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_impersonation_reveal( + fooler_name: &str, + response: &str, + guessers: BTreeSet>, + awarders: BTreeSet>, + names: &BTreeMap, String>, +) -> Embed { + let guesser_names = format_names(guessers, names); + let awarder_names = format_names(awarders, names); + + EmbedBuilder::new() + .color(COLOR_WRONG) + .title(IMPERSONATION_TITLE) + .description(response) + .field(EmbedFieldBuilder::new(AWARD_FROM_HEADER, awarder_names).build()) + .field(EmbedFieldBuilder::new(GUESSED_BY_HEADER, guesser_names).build()) + .field(EmbedFieldBuilder::new(WRITTEN_BY_HEADER, fooler_name).build()) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_subject_reveal( + subject_name: &str, + response: &str, + guessers: BTreeSet>, + awarders: BTreeSet>, + names: &BTreeMap, String>, +) -> Embed { + let (color, title) = if guessers.len() == 0 { + (COLOR_MISSED, MISSED_TITLE) + } else { + (COLOR_CORRECT, AUTHENTIC_TITLE) + }; + + let guesser_names = format_names(guessers, names); + let awarder_names = format_names(awarders, names); + + EmbedBuilder::new() + .color(color) + .title(title) + .description(response) + .field(EmbedFieldBuilder::new(AWARD_FROM_HEADER, awarder_names).build()) + .field(EmbedFieldBuilder::new(FOUND_BY_HEADER, guesser_names).build()) + .field(EmbedFieldBuilder::new(WRITTEN_BY_HEADER, subject_name).build()) + .validate() + .unwrap() + .build() +} + +fn english_for_placement(n: usize) -> String { + match n % 100 { + 0 => format!("{n}th"), + 1 => format!("{n}st"), + 2 => format!("{n}nd"), + 3 => format!("{n}rd"), + 4..=20 => format!("{n}th"), + _ => match n % 10 { + 0 => format!("{n}th"), + 1 => format!("{n}st"), + 2 => format!("{n}nd"), + 3 => format!("{n}rd"), + _ => format!("{n}th"), + }, + } +} + +pub fn embed_for_midgame_leaderboard( + leaderboard: BTreeMap>)>, + names: &BTreeMap, String>, +) -> Embed { + let lines = leaderboard + .into_iter() + .rev() + .map(|(score, (_placement, players))| { + let _tied = players.len() > 1; + + let player_names = format_names(players, names); + + let line = format!("**{score}** points: **{player_names}**"); + // let mut line = format!("{score} points: {player_names} ("); + + // if tied { + // line.push_str("tied for "); + // line.push_str(&english_for_placement(placement)); + // line.push(')'); + // } else { + // line.push_str(&english_for_placement(placement)); + // line.push_str(" place)"); + // } + + line + }); + let description = itertools::join(lines, "\n"); + + EmbedBuilder::new() + .title(MIDGAME_LEADERBOARD_TITLE) + .description(description) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_prefix_endgame_results() -> Embed { + EmbedBuilder::new() + .title(PREFIX_ENDGAME_HEADER) + .description(PREFIX_ENDGAME_DESCRIPTION) + .validate() + .unwrap() + .build() +} + +pub fn embed_for_endgame_placement( + placement: usize, + players: BTreeSet>, + score: u16, + names: &BTreeMap, String>, +) -> Embed { + let num_players = players.len(); + let tied = num_players > 1; + let player_names = format_names(players, names); + + let (color, title, description) = match placement { + 0 => unreachable!(), + 1 => { + if tied { + let gold_medals = itertools::join(repeat("🥇").take(num_players), " "); + ( + Some(GOLD_COLOR), + format!("The Overall Winners: {player_names} with {score} points!"), + format!( + "Congratulations! Enjoy the spotlight together!\nAnd how could I forget? A gold medal for each of you: {gold_medals}" + ), + ) + } else { + (Some(GOLD_COLOR), format!("The Overall Winner: {player_names}!"), "You shone above everyone else; great work!\nHere's your gold medal to commemorate the occasion: 🥇".into()) + } + } + 2 => ( + Some(SILVER_COLOR), + SILVER_TITLE.into(), + if tied { + let silver_medals = itertools::join(repeat("🥈").take(num_players), " "); + format!( + "{player_names} with {score} points!\nJust one placement away from first! For that, you've each earned a silver medal: {silver_medals}\n\nNot to spoil your celebration, but wait, who actually got first?!" + ) + } else { + "{player_names} with {score} points!\nFor your impressive performance this game: 🥈" + .into() + }, + ), + 3 => ( + Some(BRONZE_COLOR), + BRONZE_TITLE.into(), + if tied { + let bronze_medals = itertools::join(repeat("🥉").take(num_players), " "); + format!( + "{player_names} with {score} points!\nWe got bronze medals made just for you guys: {bronze_medals}" + ) + } else { + "{player_names} with {score} points!\nI think this belongs to you: 🥉".into() + }, + ), + more => ( + None, + format!("{} Place", english_for_placement(more)), + format!("{player_names} with {score} points"), + ), + }; + let mut builder = EmbedBuilder::new(); + if let Some(color) = color { + builder = builder.color(color); + } + + builder + .title(title) + .description(description) + .validate() + .unwrap() + .build() +} + +fn how_to_create(create_command_id: Id) -> String { + format!( + "As long as the bot's online (remember this is the **unstable** edition of it for playtesting), you can use the `/create` command to create a lobby and play.\nOr click this if it works: " + ) +} + +pub fn embed_for_thanks_and_how_to_create(create_command_id: Id) -> Embed { + EmbedBuilder::new() + .title(THANKS_FOR_PLAYING_HEADER) + .description(how_to_create(create_command_id)) + .footer(EmbedFooterBuilder::new(DISCLAIMER_END).build()) + .validate() + .unwrap() + .build() +} diff --git a/discord-bot/src/game.rs b/discord-bot/src/game.rs new file mode 100644 index 0000000..b3a9a61 --- /dev/null +++ b/discord-bot/src/game.rs @@ -0,0 +1,1739 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, + time::{Duration, Instant}, +}; + +use indexmap::IndexMap; +use rand::{ + Rng, rng, + seq::{IteratorRandom, SliceRandom}, +}; +use secrecy::{ExposeSecret, SecretString}; +use tokio::{ + pin, select, + sync::{mpsc, oneshot}, + time::{MissedTickBehavior, interval, sleep}, +}; +use twilight_model::{ + application::interaction::Interaction, + channel::message::{ + Component, EmojiReactionType, MessageFlags, + component::{ActionRow, Button, ButtonStyle}, + }, + http::interaction::{InteractionResponse, InteractionResponseType}, + id::{ + Id, + marker::{ApplicationMarker, ChannelMarker, CommandMarker, InteractionMarker, UserMarker}, + }, +}; +use twilight_util::builder::InteractionResponseDataBuilder; +use uuid::Uuid; + +use crate::{ + discord_formatting::{ + embed_for_all_guesses_and_awards_in, embed_for_all_responses_in, + embed_for_autoclosing_lobby, embed_for_award_recorded, embed_for_endgame_placement, + embed_for_focused_response, embed_for_game_already_running, embed_for_guess_recorded, + embed_for_impersonation_reveal, embed_for_main_lobby_message, embed_for_may_not_abort, + embed_for_midgame_leaderboard, embed_for_new_turn_about_player_and_topic, + embed_for_no_responses, embed_for_no_self_awarding, embed_for_no_self_guessing, + embed_for_no_subject_guessing, embed_for_not_meeting_minimum_necessary_players, + embed_for_not_time_to_award, embed_for_not_time_to_guess, embed_for_not_time_to_respond, + embed_for_postfix_guessing_phase, embed_for_prefix_endgame_results, + embed_for_prefix_guessing_phase, embed_for_private_lobby_controls, + embed_for_response_recorded, embed_for_subject_reveal, embed_for_successful_abort, + embed_for_thanks_and_how_to_create, embed_for_time_up_not_all_guesses_or_awards_in, + embed_for_time_up_not_all_responses_in, embed_for_unrecognized_button, + embed_for_writing_responses_phase, embed_to_say_already_in_game, + embed_to_say_already_not_in_game, embed_to_say_goodbye_from_game, + embed_to_say_hello_into_game, + }, + topic_picker, + topics::Topic, +}; + +#[derive(Debug)] +pub enum Action { + // Join { + // user: Id, + // name: String, + // }, + // Start, + // Guess { + // user: Id, + // name: String, + // response_author: Id, + // }, + Press { + user: Id, + name: String, + button: Uuid, + interaction_id: Id, + interaction_token: SecretString, + }, + Respond { + user: Id, + name: String, + response: String, + interaction_id: Id, + interaction_token: SecretString, + }, + // Leave { + // user: Id, + // }, +} + +#[derive(Debug)] +enum ButtonAction { + Join, + Leave, + Start, + Abort, + // ShowSettingsMenu, + Guess { author: Id }, + Award { author: Id }, +} + +const AUTOCLOSE_MINS: u64 = 15; + +#[derive(Debug, Clone)] +struct Settings { + write_time: Duration, + guess_time: Duration, + + max_p: f64, + + found_truth: u16, + fooled_someone: u16, + reputation: u16, + award: u16, +} + +impl Default for Settings { + fn default() -> Self { + Self { + write_time: Duration::from_secs(90), + guess_time: Duration::from_secs(90), + + max_p: 2., + + found_truth: 4, + fooled_someone: 3, + reputation: 3, + award: 1, + } + } +} + +#[derive(Debug, Clone, Default)] +struct Performance { + found_truth: u8, + fooled_someone: u8, + reputation: u8, + award: u8, +} + +fn score(performance: &Performance, settings: &Settings) -> u16 { + (performance.found_truth as u16 * settings.found_truth) + + (performance.fooled_someone as u16 * settings.fooled_someone) + + (performance.reputation as u16 * settings.reputation) + + (performance.award as u16 * settings.award) +} + +#[tracing::instrument(skip(discord_client, initiation, actions, topic_picker))] +pub async fn actor( + discord_client: Arc, + application_id: Id, + channel_id: Id, + create_command_id: Id, + respond_command_id: Id, + initiation: Interaction, + mut actions: mpsc::Receiver, + topic_picker: mpsc::Sender, +) { + let settings = Settings::default(); + let mut performances = IndexMap::<_, Performance>::new(); + let mut names = BTreeMap::new(); + let mut buttons = BTreeMap::new(); + + let lobby_runner = initiation.author().unwrap(); + let lobby_runner_id = lobby_runner.id; + names.insert( + lobby_runner_id, + lobby_runner + .global_name + .clone() + .unwrap_or_else(|| lobby_runner.name.clone()), + ); + + let start_button_id = Uuid::now_v7(); + buttons.insert(start_button_id, ButtonAction::Start); + let start_button = Button { + custom_id: Some(start_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { + name: "▶️".into() + }), + label: Some("Start".into()), + style: ButtonStyle::Primary, + url: None, + sku_id: None, + }; + + let abort_button_id = Uuid::now_v7(); + buttons.insert(abort_button_id, ButtonAction::Abort); + let abort_button = Button { + custom_id: Some(abort_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { + name: "🗑️".into() + }), + label: Some("Abort".into()), + style: ButtonStyle::Danger, + url: None, + sku_id: None, + }; + + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + initiation.id, + &initiation.token, + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .flags(MessageFlags::EPHEMERAL) + .components([Component::ActionRow(ActionRow { + components: vec![ + Component::Button(start_button), + Component::Button(abort_button), + ], + })]) + .embeds([embed_for_private_lobby_controls()]) + .build(), + ), + }, + ) + .await + { + tracing::error!(?error, ?channel_id, "failed to create a lobby"); + return; + } + + let join_button_id = Uuid::now_v7(); + buttons.insert(join_button_id, ButtonAction::Join); + let join_button = Button { + custom_id: Some(join_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { name: "➕".into() }), + label: Some("Join".into()), + style: ButtonStyle::Success, + url: None, + sku_id: None, + }; + + let leave_button_id = Uuid::now_v7(); + buttons.insert(leave_button_id, ButtonAction::Leave); + let leave_button = Button { + custom_id: Some(leave_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { name: "➖".into() }), + label: Some("Leave".into()), + style: ButtonStyle::Secondary, + url: None, + sku_id: None, + }; + + let main_lobby_message = match discord_client + .create_message(channel_id) + .embeds(&[embed_for_main_lobby_message( + lobby_runner_id, + performances.keys().copied(), + &names, + )]) + .components(&[Component::ActionRow(ActionRow { + components: vec![ + Component::Button(join_button), + Component::Button(leave_button), + ], + })]) + .await + { + Ok(main_lobby_message_response) => match main_lobby_message_response.model().await { + Ok(main_lobby_message) => main_lobby_message, + Err(error) => { + tracing::error!( + ?error, + ?channel_id, + "failed to keep track of the main lobby message in order to update it as players file in" + ); + return; + } + }, + Err(error) => { + tracing::error!( + ?error, + ?channel_id, + "failed to announce a lobby has been created" + ); + return; + } + }; + + // region: Join and settings time + let join_time_over = sleep(Duration::from_secs(AUTOCLOSE_MINS * 60)); + pin!(join_time_over); + + loop { + select! { + biased; + + action_option = actions.recv() => { + let Some(action) = action_option else { + tracing::error!("handle to this actor's channel was dropped somehow"); + + if let Err(error) = discord_client.create_message(channel_id).content("game malfunctioned sorry").await { + tracing::error!(?error); + } + return; + }; + + match action { + Action::Press { user, name, button, interaction_id, interaction_token } => { + names.insert(user, name); + + match buttons.get(&button) { + None => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_unrecognized_button()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + Some(ButtonAction::Join) => { + match performances.entry(user) { + indexmap::map::Entry::Vacant(vacant_entry) => { + vacant_entry.insert(Default::default()); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_hello_into_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + + if let Err(error) = discord_client.update_message(channel_id, main_lobby_message.id) + .embeds(Some(&[embed_for_main_lobby_message( + lobby_runner_id, + performances.keys().copied(), + &names, + )])) + .await { + tracing::warn!(?error, "failed to update the main lobby message to show this user joined"); + } + }, + indexmap::map::Entry::Occupied(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user they're already in the game"); + } + }, + } + } + Some(ButtonAction::Leave) => { + match performances.entry(user) { + indexmap::map::Entry::Vacant(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_not_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + indexmap::map::Entry::Occupied(occupied_entry) => { + occupied_entry.shift_remove(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_goodbye_from_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + + if let Err(error) = discord_client.update_message(channel_id, main_lobby_message.id) + .embeds(Some(&[embed_for_main_lobby_message( + lobby_runner_id, + performances.keys().copied(), + &names, + )])) + .await { + tracing::warn!(?error, "failed to update the main lobby message to show this user left"); + } + }, + } + }, + Some(ButtonAction::Start) => { + if performances.len() < 3 { + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .flags(MessageFlags::EPHEMERAL) + .embeds([embed_for_not_meeting_minimum_necessary_players()]) + .build(), + ), + }, + ) + .await { + + tracing::warn!(?error, "failed to inform the user that the game cannot start yet because it doesn't yet have the minimum number of players"); + } + } else { + break; + } + }, + Some(ButtonAction::Abort) => { + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .flags(MessageFlags::EPHEMERAL) + .embeds([embed_for_successful_abort()]) + .build(), + ), + }, + ) + .await { + + tracing::warn!(?error, "failed to inform the user that they have aborted the game lobby"); + } + + if let Err(error) = discord_client.delete_message(channel_id, main_lobby_message.id).await { + tracing::error!(?error, "failed to delete the message showing there's an open lobby"); + } + + return; + } + Some(ButtonAction::Guess { author: _ }) => { + performances.entry(user).or_default(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_guess()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + Some(ButtonAction::Award { author: _ }) => { + performances.entry(user).or_default(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_award()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + } + } + }, + Action::Respond { user, name, response, interaction_id, interaction_token } => { + names.insert(user, name); + performances.entry(user).or_default(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_respond(response)]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "failed to inform the user it's not time to respond"); + } + }, + } + } + + _ = &mut join_time_over => { + if let Err(error) = discord_client.create_message(channel_id) + .reply(main_lobby_message.id) + .embeds(&[embed_for_autoclosing_lobby()]) + .await { + + tracing::warn!(?error, "failed to announce that the lobby is autoclosing after the timeout occurred"); + } + + break; + } + } + } + + // endregion: Join and settings time + + let (initial_topics_sender, initial_topics_receiver) = oneshot::channel(); + + topic_picker + .send(topic_picker::Message::GiveTopics { + existing: vec![], + desired: performances.len(), + max_p: settings.max_p, + callback: initial_topics_sender, + }) + .await + .unwrap(); + + let mut topics = initial_topics_receiver.await.unwrap(); + + // region: Each player's turn + + let mut needs_to_go = Vec::from_iter(performances.keys().copied()); + let mut already_gone = BTreeSet::new(); + + let mut turn = 0; + + while let Some(subject) = { random_remove_no_preserve_order(&mut needs_to_go, &mut rng()) } { + already_gone.insert(subject); + + let (_topic_id, topic) = topics[turn]; + turn += 1; + + let topic = match topic { + Topic::Text(text) => GameTopic::Text(text), + Topic::Player => { + let candidates = names + .iter() + .filter_map(|(&id, name)| (id != subject).then_some(name)); + + let player = candidates.choose(&mut rng()).unwrap(); + + GameTopic::Player(player.to_owned()) + } + Topic::None => GameTopic::None, + }; + + let subject_name = names.get(&subject).unwrap().to_owned(); + + // region: Announcing it's this player's turn to be the subject + + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_new_turn_about_player_and_topic(&subject_name)]) + .await + { + tracing::warn!( + ?error, + ?subject_name, + "failed to introduce that this player is the subject of this turn" + ); + } + + // endregion: Announcing it's this player's turn to be the subject + + // region: Writing responses + + let mut responses = BTreeMap::new(); + + let main_writing_phase_message = match discord_client + .create_message(channel_id) + .embeds(&[embed_for_writing_responses_phase( + &subject_name, + &topic, + respond_command_id, + settings.write_time, + )]) + .await + { + Ok(main_writing_phase_response) => match main_writing_phase_response.model().await { + Ok(main_writing_phase_message) => main_writing_phase_message, + Err(error) => { + tracing::error!( + ?error, + "failed to keep track of the main writing phase message in order to update it as the timer runs" + ); + continue; + } + }, + Err(error) => { + tracing::error!( + ?error, + ?channel_id, + "failed to announce the writing phase has started" + ); + return; + } + }; + + let mut time_shown_in_message = settings.write_time; + + let end_time = Instant::now() + settings.write_time; + + let writing_time_up = sleep(settings.write_time); + pin!(writing_time_up); + + let mut about_every_half_second = interval(Duration::from_millis(500)); + about_every_half_second.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + select! { + biased; + action_option = actions.recv() => { + let Some(action) = action_option else { + tracing::error!("handle to this actor's channel was dropped somehow"); + + if let Err(error) = discord_client.create_message(channel_id).content("game malfunctioned sorry").await { + tracing::error!(?error); + } + return; + }; + + match action { + Action::Press { user, name, button, interaction_id, interaction_token } => { + names.insert(user, name); + + match buttons.get(&button) { + None => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_unrecognized_button()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + } + Some(ButtonAction::Join) => { + match performances.entry(user) { + indexmap::map::Entry::Vacant(vacant_entry) => { + vacant_entry.insert(Default::default()); + + let (new_topics_sender, new_topics_receiver) = oneshot::channel(); + topic_picker.send(topic_picker::Message::GiveTopics { + existing: topics, + desired: performances.len(), + max_p: settings.max_p, + callback: new_topics_sender, + }).await.unwrap(); + topics = new_topics_receiver.await.unwrap(); + + if !already_gone.contains(&user) { + insert_into_vec_without_duplicating(&mut needs_to_go, user); + } + }, + indexmap::map::Entry::Occupied(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + } + }, + Some(ButtonAction::Leave) => { + responses.remove(&user); + + match performances.entry(user) { + indexmap::map::Entry::Vacant(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_not_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + indexmap::map::Entry::Occupied(occupied_entry) => { + occupied_entry.shift_remove(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_goodbye_from_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + } + }, + Some(ButtonAction::Start) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_game_already_running()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user the game is already running"); + } + }, + Some(ButtonAction::Abort) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_may_not_abort()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user the game they may not abort the game anymore"); + } + } + Some(ButtonAction::Guess { author: _ }) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_guess()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user it's not time to guess"); + } + }, + Some(ButtonAction::Award { author: _ }) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_award()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user it's not time to award"); + } + } + } + }, + Action::Respond { user, name, response, interaction_id, interaction_token } => { + names.insert(user, name); + performances.entry(user).or_default(); + if !already_gone.contains(&user) { + insert_into_vec_without_duplicating(&mut needs_to_go, user); + } + + responses.insert(user, response); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_response_recorded()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + + if responses.len() == performances.len() { + if let Err(error) = discord_client.create_message(channel_id) + .embeds(&[embed_for_all_responses_in()]) + .await { + + tracing::error!(?error, "failed to announce that everyone's response is in"); + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + break; + } + } + } + } + _ = &mut writing_time_up => { + if let Err(error) = discord_client.create_message(channel_id) + .embeds(&[embed_for_time_up_not_all_responses_in()]) + .await { + + tracing::error!(?error, "failed to announce that not everyone got a response in but we still have to move on"); + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + break; + } + _ = about_every_half_second.tick() => { + let remaining_time = end_time - Instant::now(); + + let difference_between_reported_and_actual = time_shown_in_message - remaining_time; + + let should_update = if remaining_time <= Duration::from_secs(10) { + true + } else if remaining_time <= Duration::from_secs(60) { + difference_between_reported_and_actual >= Duration::from_secs(5) + } else if remaining_time <= Duration::from_secs(120) { + difference_between_reported_and_actual >= Duration::from_secs(10) + } else { + difference_between_reported_and_actual >= Duration::from_secs(30) + }; + + if should_update { + match discord_client.update_message(channel_id, main_writing_phase_message.id) + .embeds(Some(&[embed_for_writing_responses_phase( + &subject_name, + &topic, + respond_command_id, + remaining_time, + )])) + .await { + + Ok(_message_response) => { + time_shown_in_message = remaining_time; + } + Err(error) => { + tracing::error!(?time_shown_in_message, ?remaining_time, ?error, "could not inform players that their time remaining has gotten significantly lower"); + } + } + } + } + } + } + + // endregion: Writing responses + + if responses.len() == 0 { + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_no_responses()]) + .await + { + tracing::warn!( + ?error, + "failed to announce that no responses came in so we're moving on to the next" + ); + } + + continue; + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + // region: Guessing and awarding + + if let Err(err) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_prefix_guessing_phase(&subject_name, &topic)]) + .await + { + tracing::error!(?err, "failed to announce the guessing phase (prefix)"); + } + + let reading_time = Duration::from_secs(4); + sleep(reading_time).await; + + let mut responses_random_order = Vec::from_iter(responses.iter()); + responses_random_order.shuffle(&mut rng()); + + let mut current_turn_buttons = Vec::new(); + + for (&author, response) in responses_random_order { + let guess_button_id = Uuid::now_v7(); + buttons.insert(guess_button_id, ButtonAction::Guess { author }); + current_turn_buttons.push(guess_button_id); + let guess_button = Button { + custom_id: Some(guess_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { + name: "🔘".into() + }), + label: Some(format!("Guess that {subject_name} wrote this",)), + style: ButtonStyle::Success, + url: None, + sku_id: None, + }; + + let award_button_id = Uuid::now_v7(); + buttons.insert(award_button_id, ButtonAction::Award { author }); + current_turn_buttons.push(award_button_id); + let award_button = Button { + custom_id: Some(award_button_id.to_string()), + disabled: false, + emoji: Some(EmojiReactionType::Unicode { + name: "🏆".into() + }), + label: Some("Give your award this turn".into()), + style: ButtonStyle::Secondary, + url: None, + sku_id: None, + }; + + if let Err(err) = discord_client + .create_message(channel_id) + .content(&response) + .components(&[Component::ActionRow(ActionRow { + components: vec![ + Component::Button(guess_button), + Component::Button(award_button), + ], + })]) + .await + { + tracing::error!(?err); + } + + let small_amount_of_reading_time = Duration::from_secs(3); + sleep(small_amount_of_reading_time).await; + } + + let postfix_guessing_phase_message = match discord_client + .create_message(channel_id) + .embeds(&[embed_for_postfix_guessing_phase( + &subject_name, + &topic, + settings.guess_time, + )]) + .await + { + Ok(postfix_guessing_phase_response) => { + match postfix_guessing_phase_response.model().await { + Ok(postfix_guessing_phase_message) => Some(postfix_guessing_phase_message), + Err(error) => { + tracing::warn!( + ?error, + "failed to keep track of the main guessing phase message in order to update it as the timer runs out. however, we'll still continue anyway because it's not strictly required to be able to play" + ); + + None + } + } + } + + Err(error) => { + tracing::error!( + ?error, + "failed to explain in detail what to do during this phase" + ); + + None + } + }; + + let mut guesses = BTreeMap::new(); + let mut awards = BTreeMap::new(); + + let mut time_shown_in_message = settings.guess_time; + + let end_time = Instant::now() + settings.guess_time; + + let guessing_time_up = sleep(settings.guess_time); + pin!(guessing_time_up); + + let mut about_every_half_second = interval(Duration::from_millis(500)); + about_every_half_second.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + select! { + biased; + action_option = actions.recv() => { + let Some(action) = action_option else { + tracing::error!("handle to this actor's channel was dropped somehow"); + + if let Err(error) = discord_client.create_message(channel_id).content("game malfunctioned sorry").await { + tracing::error!(?error); + } + return; + }; + + match action { + Action::Press { user, name, button, interaction_id, interaction_token } => { + names.insert(user, name); + + match buttons.get(&button) { + None => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_unrecognized_button()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + Some(ButtonAction::Join) => { + match performances.entry(user) { + indexmap::map::Entry::Vacant(vacant_entry) => { + vacant_entry.insert(Default::default()); + + let (new_topics_sender, new_topics_receiver) = oneshot::channel(); + topic_picker.send(topic_picker::Message::GiveTopics { + existing: topics, + desired: performances.len(), + max_p: settings.max_p, + callback: new_topics_sender, + }).await.unwrap(); + topics = new_topics_receiver.await.unwrap(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_hello_into_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + indexmap::map::Entry::Occupied(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + } + + if !already_gone.contains(&user) { + insert_into_vec_without_duplicating(&mut needs_to_go, user); + } + }, + Some(ButtonAction::Leave) => { + match performances.entry(user) { + indexmap::map::Entry::Vacant(_) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_already_not_in_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + indexmap::map::Entry::Occupied(occupied_entry) => { + occupied_entry.shift_remove(); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_to_say_goodbye_from_game()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user they have left the game"); + } + }, + } + }, + Some(ButtonAction::Start) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_game_already_running()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user they have left the game"); + } + }, + Some(ButtonAction::Abort) => { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_may_not_abort()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error, "failed to inform the user the game they may not abort the game anymore"); + } + } + Some(ButtonAction::Guess { author }) => { + let author = *author; + + performances.entry(user).or_default(); + + if user == subject { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_no_subject_guessing()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "couldn't inform that they cannot guess when they're the subject of the turn"); + } + + continue; + } + + if user == author { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_no_self_guessing()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "couldn't inform that they cannot guess when they're the subject of the turn"); + } + + continue; + } + + guesses.insert(user, author); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_guess_recorded()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "couldn't inform that their guess was recorded"); + } + + + if guesses.len() == performances.len().saturating_sub(1) && awards.len() == performances.len() { + if let Err(err) = discord_client.create_message(channel_id) + .embeds(&[embed_for_all_guesses_and_awards_in()]) + .await { + tracing::error!(?err); + } + + break; + } + }, + Some(ButtonAction::Award { author }) => { + let author = *author; + + performances.entry(user).or_default(); + if !already_gone.contains(&user) { + insert_into_vec_without_duplicating(&mut needs_to_go, user); + } + + if user == author { + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_no_self_awarding()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "couldn't inform that they cannot award bonus points to their own response"); + } + + continue; + } + + awards.insert(user, author); + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_award_recorded()]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::warn!(?error, ?user, "couldn't inform that their award was recorded"); + } + + if guesses.len() == performances.len().saturating_sub(1) && awards.len() == performances.len() { + if let Err(err) = discord_client.create_message(channel_id) + .embeds(&[embed_for_all_guesses_and_awards_in()]) + .await { + tracing::error!(?err); + } + + break; + } + } + } + } + Action::Respond { user, name, response, interaction_id, interaction_token } => { + names.insert(user, name); + performances.entry(user).or_default(); + if !already_gone.contains(&user) { + insert_into_vec_without_duplicating(&mut needs_to_go, user); + } + + if let Err(error) = discord_client.interaction(application_id).create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(InteractionResponseDataBuilder::new() + .embeds([embed_for_not_time_to_respond(response)]) + .flags(MessageFlags::EPHEMERAL) + .build() + ) + } + ).await { + tracing::error!(?error); + } + }, + } + } + _ = &mut guessing_time_up => { + if let Err(err) = discord_client.create_message(channel_id) + .embeds(&[embed_for_time_up_not_all_guesses_or_awards_in()]) + .await { + + tracing::error!(?err); + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + break; + } + _ = about_every_half_second.tick() => { + let remaining_time = end_time - Instant::now(); + + let difference_between_reported_and_actual = time_shown_in_message - remaining_time; + + let should_update = if remaining_time <= Duration::from_secs(10) { + true + } else if remaining_time <= Duration::from_secs(60) { + difference_between_reported_and_actual >= Duration::from_secs(5) + } else if remaining_time <= Duration::from_secs(120) { + difference_between_reported_and_actual >= Duration::from_secs(10) + } else { + difference_between_reported_and_actual >= Duration::from_secs(30) + }; + + if should_update { + if let Some(postfix_guessing_phase_message) = &postfix_guessing_phase_message { + match discord_client.update_message(channel_id, postfix_guessing_phase_message.id) + .embeds(Some(&[embed_for_postfix_guessing_phase( + &subject_name, + &topic, + remaining_time, + )])) + .await { + + Ok(_message_response) => { + time_shown_in_message = remaining_time; + } + Err(error) => { + tracing::error!(?time_shown_in_message, ?remaining_time, ?error, "could not inform players that their time remaining has gotten significantly lower"); + } + } + } + } + } + } + } + + // Prevent cross-turn leaks / cheating + for button_id in current_turn_buttons { + buttons.remove(&button_id); + } + + // endregion: Guessing and awarding + + // region: Showing this turn's results + + let mut guessers_of_response = BTreeMap::<_, BTreeSet<_>>::new(); + for (&guesser, &author) in &guesses { + guessers_of_response + .entry(author) + .or_default() + .insert(guesser); + } + + let mut awarders_of_response = BTreeMap::<_, BTreeSet<_>>::new(); + for (&awarder, &author) in &awards { + awarders_of_response + .entry(author) + .or_default() + .insert(awarder); + } + + let guessed_subject = guessers_of_response.remove(&subject).unwrap_or_default(); + + // region: Responses that fooled someone + + let mut fooled_guessers = Vec::from_iter(guessers_of_response); + fooled_guessers.shuffle(&mut rng()); + + for (author, guessers) in fooled_guessers { + let fooler_name = names.get(&author).unwrap(); + + let response = responses.get(&author).unwrap(); + let awarders = awarders_of_response.remove(&author).unwrap_or_default(); + + match discord_client + .create_message(channel_id) + .embeds(&[embed_for_focused_response( + &response, + guessers.clone(), + awarders.clone(), + &names, + )]) + .await + { + Ok(message_response) => match message_response.model().await { + Ok(message) => { + // Delay before showing the answer so people have time to read (and for drama) + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + if let Err(error) = discord_client + .update_message(channel_id, message.id) + .embeds(Some(&[embed_for_impersonation_reveal( + fooler_name, + &response, + guessers.clone(), + awarders.clone(), + &names, + )])) + .await + { + tracing::error!( + ?error, + "couldn't update the message to show who the actual author (an impersonator) was!" + ); + } + } + Err(error) => { + tracing::error!( + ?error, + "couldn't deserialize the sent message to be able to update it with the actual author" + ); + } + }, + Err(error) => { + tracing::error!( + ?error, + "couldn't send a message showing how this guess shook out; the points will still count but we have no choice but to move on to the next one" + ); + continue; + } + } + + // Delay before showing the next one so people have time to read + sleep(Duration::from_millis(2500)).await; + } + + // endregion: Responses that fooled someone + + // region: The subject's response + + let response = responses.get(&subject).unwrap(); + let awarders = awarders_of_response.remove(&subject).unwrap_or_default(); + + match discord_client + .create_message(channel_id) + .embeds(&[embed_for_focused_response( + response, + guessed_subject.clone(), + awarders.clone(), + &names, + )]) + .await + { + Ok(message_response) => match message_response.model().await { + Ok(message) => { + // Delay before showing the answer so people have time to read (and for drama) + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + if let Err(error) = discord_client + .update_message(channel_id, message.id) + .embeds(Some(&[embed_for_subject_reveal( + &subject_name, + &response, + guessed_subject, + awarders, + &names, + )])) + .await + { + tracing::error!( + ?error, + "couldn't update the message to show who the actual author (the subject) was!" + ); + } + } + Err(error) => { + tracing::error!( + ?error, + "couldn't deserialize the sent message to be able to update it with the actual author" + ); + } + }, + Err(error) => { + tracing::error!( + ?error, + "couldn't send a message showing how this guess shook out; the points will still count but we have no choice but to move on to the next one" + ); + continue; + } + } + // endregion: The subject's response + + // region: Calculate and apply performance / score changes this turn (to overall) + let mut performances_this_turn = BTreeMap::<_, Performance>::new(); + + for (made_guess, was_guessed) in guesses { + if was_guessed == subject { + performances_this_turn + .entry(made_guess) + .or_default() + .found_truth += 1; + performances_this_turn + .entry(was_guessed) + .or_default() + .reputation += 1; + } else { + performances_this_turn + .entry(was_guessed) + .or_default() + .fooled_someone += 1; + } + } + + for (gave_award, received_award) in awards { + performances_this_turn.entry(gave_award).or_default().award += 1; + performances_this_turn + .entry(received_award) + .or_default() + .award += 1; + } + + let score_gains_this_turn = BTreeMap::from_iter( + performances_this_turn + .iter() + .map(|(&user, performance)| (user, score(performance, &settings))), + ); + + tracing::info!(?performances_this_turn); + tracing::info!(?score_gains_this_turn); + + for (user, this_turn) in performances_this_turn { + let cumulative = performances.entry(user).or_default(); + + cumulative.award += this_turn.award; + cumulative.fooled_someone += this_turn.fooled_someone; + cumulative.found_truth += this_turn.found_truth; + cumulative.reputation += this_turn.reputation; + } + + let scores = performances + .iter() + .map(|(&user_id, performance)| (user_id, score(&performance, &settings))) + .collect(); + + // endregion: Calculate and apply performance / score changes this turn (to overall) + + // region: Show the leaderboard (currently) + + let leaderboard = create_leaderboard(scores); + + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_midgame_leaderboard(leaderboard, &names)]) + .await + { + tracing::warn!(?error, "failed to show a mid-game leaderboard"); + } + + // endregion: Show the leaderboard (currently) + + // endregion: Showing this turn's results + } + // endregion: Each player's turn + + if let Err(error) = discord_client + .create_message(channel_id) + .content("that's the end of the first round folks! a cool different round *might* go here but I haven't implemented it yet for this playtest. see you in the future (maybe)") + .await + { + tracing::warn!(?error, "failed to inform of the missing inverse round I have in mind"); + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_prefix_endgame_results()]) + .await + { + tracing::warn!(?error, "failed to introduce the endgame placements"); + } + + let reading_time = Duration::from_secs(2); + sleep(reading_time).await; + + let scores = BTreeMap::from_iter( + performances + .into_iter() + .map(|(player_id, performance)| (player_id, score(&performance, &settings))), + ); + tracing::info!(?scores); + + let leaderboard = create_leaderboard(scores); + + for (score, (placement, players)) in leaderboard { + let drama = Duration::from_secs(5).div_f64(placement as f64); + sleep(drama).await; + + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_endgame_placement( + placement, players, score, &names, + )]) + .await + { + tracing::warn!( + ?error, + ?score, + ?placement, + "could not show this placement at the end of the round" + ); + } + } + + let time_focus_on_winners_before_saying_end_of_game = Duration::from_secs(3); + sleep(time_focus_on_winners_before_saying_end_of_game).await; + + if let Err(error) = discord_client + .create_message(channel_id) + .embeds(&[embed_for_thanks_and_how_to_create(create_command_id)]) + .await + { + tracing::warn!( + ?error, + "could not thank the players for playing and teach them how to create a lobby" + ); + } +} + +fn random_remove_no_preserve_order(seq: &mut Vec, rng: &mut R) -> Option { + let len = seq.len(); + if len == 0 { + return None; + } + let random_index = rng.random_range(0..len); + Some(seq.swap_remove(random_index)) +} + +fn insert_into_vec_without_duplicating(vec: &mut Vec, value: T) { + if !vec.contains(&value) { + vec.push(value); + } +} + +#[derive(Debug)] +pub enum GameTopic { + Text(&'static str), + Player(String), + None, +} + +fn create_leaderboard( + scores: BTreeMap, u16>, +) -> BTreeMap>)> { + let mut scores_with_ties = BTreeMap::<_, BTreeSet<_>>::new(); + + for (player_id, score) in scores { + scores_with_ties.entry(score).or_default().insert(player_id); + } + + let highest_to_lowest = scores_with_ties.into_iter().rev(); + + let mut placement = 1; + + let mut leaderboard = BTreeMap::new(); + + for (score, players) in highest_to_lowest { + let advance = players.len(); + + leaderboard.insert(score, (placement, players)); + + placement += advance; + } + + leaderboard +} diff --git a/discord-bot/src/lib.rs b/discord-bot/src/lib.rs new file mode 100644 index 0000000..02dad7a --- /dev/null +++ b/discord-bot/src/lib.rs @@ -0,0 +1,5 @@ +pub mod discord_formatting; +pub mod game; +pub mod lobbies; +pub mod topic_picker; +pub mod topics; diff --git a/discord-bot/src/lobbies.rs b/discord-bot/src/lobbies.rs new file mode 100644 index 0000000..17cd16e --- /dev/null +++ b/discord-bot/src/lobbies.rs @@ -0,0 +1,214 @@ +use opendal::Operator; +use secrecy::ExposeSecret; +use std::{ + collections::{BTreeMap, btree_map::Entry}, + sync::Arc, +}; +use tokio::sync::mpsc; +use twilight_model::{ + application::interaction::Interaction, + http::interaction::{InteractionResponse, InteractionResponseType}, + id::{ + Id, + marker::{ApplicationMarker, ChannelMarker, CommandMarker}, + }, +}; +use twilight_util::builder::InteractionResponseDataBuilder; + +use crate::{ + discord_formatting::{ + embed_for_already_existing_game, embed_for_no_lobby_for_press, + embed_for_no_lobby_for_response, + }, + game::{Action, actor as game_actor}, + topic_picker, +}; + +#[derive(Debug)] +pub enum LobbiesMessage { + Create { + channel_id: Id, + initiation: Interaction, + }, + Action { + channel_id: Id, + action: Action, + }, + Destroy { + channel_id: Id, + }, +} + +#[tracing::instrument(skip(discord_client, message_self, messages))] +pub async fn actor( + discord_client: twilight_http::Client, + application_id: Id, + create_command_id: Id, + respond_command_id: Id, + message_self: mpsc::Sender, + mut messages: mpsc::Receiver, +) { + let discord_client = Arc::new(discord_client); + let mut lobbies = BTreeMap::new(); + + let (topic_picker_tx, topic_picker_rx) = mpsc::channel(128); + + let topic_picker_builder = opendal::services::Fs::default().root("./data/topic_picker"); + let topic_picker_operator = Operator::new(topic_picker_builder).unwrap().finish(); + + let topic_picker_task = + tokio::spawn(topic_picker::actor(topic_picker_operator, topic_picker_rx)); + + while let Some(message) = messages.recv().await { + tracing::info!(?message, "received"); + + use LobbiesMessage as M; + match message { + M::Create { + channel_id, + initiation, + } => match lobbies.entry(channel_id) { + Entry::Vacant(vacant_entry) => { + let (tx, rx) = mpsc::channel(64); + + tokio::spawn({ + let message_self = message_self.clone(); + let channel_id = channel_id.clone(); + let tx = tx.clone(); + + async move { + tx.closed().await; + message_self + .send(LobbiesMessage::Destroy { channel_id }) + .await + .unwrap(); + } + }); + + vacant_entry.insert(tx); + + tokio::spawn(game_actor( + Arc::clone(&discord_client), + application_id, + channel_id, + create_command_id, + respond_command_id, + initiation, + rx, + topic_picker_tx.clone(), + )); + } + Entry::Occupied(_occupied_entry) => { + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + initiation.id, + &initiation.token, + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .embeds([embed_for_already_existing_game()]) + .build(), + ), + }, + ) + .await + { + tracing::warn!( + ?error, + ?initiation, + "failed to inform user that a lobby is already in this channel currently" + ); + } + } + }, + M::Action { channel_id, action } => match lobbies.get(&channel_id) { + Some(game_actor_tx) => { + if game_actor_tx.send(action).await.is_err() { + tracing::error!(?channel_id, "game actor died somewhere along the way"); + } + } + None => { + tracing::warn!( + ?channel_id, + "doesn't have an active game despite commands being sent to it" + ); + + match action { + Action::Press { + user, + name, + button, + interaction_id, + interaction_token, + } => { + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .embeds([embed_for_no_lobby_for_press()]) + .build(), + ), + }, + ) + .await + { + tracing::warn!( + ?error, + ?user, + ?name, + ?button, + "failed to inform user that there isn't a lobby for their press action to go through" + ); + } + } + Action::Respond { + user, + name, + response, + interaction_id, + interaction_token, + } => { + if let Err(error) = discord_client + .interaction(application_id) + .create_response( + interaction_id, + interaction_token.expose_secret(), + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .embeds([embed_for_no_lobby_for_response(response)]) + .build(), + ), + }, + ) + .await + { + tracing::warn!( + ?error, + ?user, + ?name, + "failed to inform user that there isn't a lobby for their response to go through" + ); + } + } + } + } + }, + M::Destroy { channel_id } => { + lobbies.remove(&channel_id); + } + } + } + + drop(topic_picker_tx); + + topic_picker_task.await.unwrap(); +} diff --git a/discord-bot/src/main.rs b/discord-bot/src/main.rs index b5a016a..d688a7e 100644 --- a/discord-bot/src/main.rs +++ b/discord-bot/src/main.rs @@ -1,16 +1,24 @@ use clap::Parser; +use discord_bot::{ + game::Action, + lobbies::{LobbiesMessage, actor as lobbies_actor}, +}; use secrecy::{ExposeSecret, SecretString}; use snafu::{ResultExt, Snafu}; -use std::{error::Error, time::Duration}; -use tracing_subscriber::fmt::format::FmtSpan; +use std::{error::Error, str::FromStr}; +use tokio::sync::mpsc; +use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; use twilight_cache_inmemory::{DefaultInMemoryCache, ResourceType}; use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; use twilight_model::{ - application::{command::CommandType, interaction::InteractionData}, + application::{ + command::{CommandOption, CommandOptionType, CommandType}, + interaction::{InteractionData, application_command::CommandOptionValue}, + }, gateway::payload::incoming::InteractionCreate, - http::interaction::{InteractionResponse, InteractionResponseType}, }; use twilight_util::builder::command::CommandBuilder; +use uuid::Uuid; #[derive(Debug, Parser)] struct Args { @@ -36,6 +44,7 @@ async fn main() -> Result<(), AppError> { tracing_subscriber::fmt() .pretty() .with_span_events(FmtSpan::ACTIVE) + .with_env_filter(EnvFilter::from_default_env()) .init(); let shard_id = ShardId::ONE; @@ -46,11 +55,9 @@ async fn main() -> Result<(), AppError> { .resource_types(ResourceType::empty()) .build(); - tracing::info!("info"); + let discord_client = twilight_http::Client::new(discord_token.expose_secret().into()); - let client = twilight_http::Client::new(discord_token.expose_secret().into()); - - let current_application = client + let current_application = discord_client .current_user_application() .await .whatever_context("couldn't get current Discord application")?; @@ -62,9 +69,9 @@ async fn main() -> Result<(), AppError> { let application_id = current_application.id; - let interaction_client = client.interaction(application_id); + let interaction_client = discord_client.interaction(application_id); - let create_lobby = CommandBuilder::new( + let create_command = CommandBuilder::new( "create", "Create a lobby in this channel", CommandType::ChatInput, @@ -72,7 +79,31 @@ async fn main() -> Result<(), AppError> { .validate() .whatever_context("command wasn't correct")? .build(); - let commands = vec![create_lobby]; + let respond_command = CommandBuilder::new( + "respond", + "Write a response about the subject and topic of this turn", + CommandType::ChatInput, + ) + .option(CommandOption { + autocomplete: Some(false), + channel_types: None, + choices: None, + description: "Type your entire response in this field".into(), + description_localizations: None, + kind: CommandOptionType::String, + max_length: None, + max_value: None, + min_length: None, + min_value: None, + name: "text".into(), + name_localizations: None, + options: None, + required: Some(true), + }) + .validate() + .whatever_context("command wasn't correct")? + .build(); + let commands = vec![create_command, respond_command]; let returned_commands = interaction_client .set_global_commands(&commands) @@ -82,6 +113,21 @@ async fn main() -> Result<(), AppError> { .await .whatever_context("failed to deserialize set commands")?; tracing::info!(?returned_commands); + let [returned_create_lobby, returned_respond] = returned_commands.try_into().unwrap(); + + let create_command_id = returned_create_lobby.id.unwrap(); + let respond_command_id = returned_respond.id.unwrap(); + + let (lobbies_actor_tx, lobbies_actor_rx) = mpsc::channel(128); + + let lobbies_actor_task = tokio::spawn(lobbies_actor( + discord_client, + application_id, + create_command_id, + respond_command_id, + lobbies_actor_tx.clone(), + lobbies_actor_rx, + )); while let Some(event_res) = shard.next_event(EventTypeFlags::INTERACTION_CREATE).await { let event = match event_res { @@ -92,7 +138,7 @@ async fn main() -> Result<(), AppError> { } }; - tracing::info!(?event); + tracing::debug!(?event); cache.update(&event); match event { @@ -105,58 +151,138 @@ async fn main() -> Result<(), AppError> { let InteractionCreate(interaction) = *interaction_create; tracing::info!(?interaction); - match interaction.data { + match &interaction.data { None => { tracing::warn!("missing expected interaction data"); continue; } Some(InteractionData::ApplicationCommand(command_data)) => { - let command_data = *command_data; - match command_data.name.as_str() { "create" => { - tracing::info!("hurray for creating a lobby! TODO"); - - let initial_response = InteractionResponse { - kind: InteractionResponseType::DeferredChannelMessageWithSource, - data: None, - }; - if let Err(error) = interaction_client - .create_response( - interaction.id, - &interaction.token, - &initial_response, - ) - .await - { - tracing::error!(?error); + if let Some(channel) = &interaction.channel { + lobbies_actor_tx + .send(LobbiesMessage::Create { + channel_id: channel.id, + initiation: interaction, + }) + .await + .whatever_context( + "games actor task failed somewhere along the way", + )?; + } else { + tracing::warn!( + ?interaction, + "expected /create to be called in a channel, but it wasn't" + ); } - - tokio::time::sleep(Duration::from_secs(5)).await; - - match interaction_client - .update_response(&interaction.token) - .content(Some("hello world!")) - .await - { - Ok(message) => match message.model().await { - Ok(message) => { - tracing::info!(?message); + } + "respond" => { + match interaction.member.clone().and_then(|member| member.user) { + Some(user) => { + if let Some(channel) = &interaction.channel { + match command_data.options.iter().find_map(|option| { + (option.name == "text").then_some(&option.value) + }) { + Some(response) => match response { + CommandOptionValue::String(response) => { + lobbies_actor_tx + .send(LobbiesMessage::Action { + channel_id: channel.id, + action: Action::Respond { + user: user.id, + name: user.global_name.unwrap_or(user.name), + response: response.into(), + interaction_id: interaction.id, + interaction_token: interaction.token.into(), + }, + }) + .await + .whatever_context( + "games actor task failed somewhere along the way", + )?; + } + instead => { + tracing::warn!( + ?interaction, + ?instead, + "expected /respond's `text` to be a `String`, but it didn't" + ); + } + }, + None => { + tracing::warn!( + ?interaction, + "expected /respond to have a `text` response, but it didn't" + ); + } + } + } else { + tracing::warn!( + ?interaction, + "expected /respond to be called in a channel, but it wasn't" + ); } - Err(error) => { - tracing::error!(?error); - } - }, - Err(error) => { - tracing::error!(?error); + } + None => { + tracing::warn!( + ?interaction, + "expected /respond to have been done by a user, but it wasn't" + ); } } } command_name => { - tracing::warn!(?command_name, "did not expect command"); + tracing::warn!( + ?command_name, + ?interaction, + "did not expect command" + ); } } } + Some(InteractionData::MessageComponent(component_data)) => { + if let Some(channel) = &interaction.channel { + match interaction.member.clone().and_then(|member| member.user) { + Some(user) => match Uuid::from_str(&component_data.custom_id) { + Ok(button) => { + lobbies_actor_tx + .send(LobbiesMessage::Action { + channel_id: channel.id, + action: Action::Press { + user: user.id, + name: user.global_name.unwrap_or(user.name), + button, + interaction_id: interaction.id, + interaction_token: interaction.token.into(), + }, + }) + .await + .whatever_context( + "games actor task failed somewhere along the way", + )?; + } + Err(not_a_uuid) => { + tracing::warn!( + ?not_a_uuid, + ?interaction, + "discord says this button was pressed, but it's not a uuid, so I didn't make it" + ); + } + }, + None => { + tracing::warn!( + ?interaction, + "expected a button press to have been done by a user, but it wasn't" + ); + } + } + } else { + tracing::warn!( + ?interaction, + "expected a button press to be done in a channel (the only way a lobby can be created), but it wasn't" + ); + } + } _ => {} } } @@ -164,5 +290,11 @@ async fn main() -> Result<(), AppError> { } } + drop(lobbies_actor_tx); + + lobbies_actor_task + .await + .whatever_context("games actor task failed somewhere along the way")?; + Ok(()) } diff --git a/discord-bot/src/topic_picker.rs b/discord-bot/src/topic_picker.rs new file mode 100644 index 0000000..b749205 --- /dev/null +++ b/discord-bot/src/topic_picker.rs @@ -0,0 +1,172 @@ +use std::collections::BTreeSet; + +use opendal::Operator; +use postcard::{from_bytes, to_stdvec}; +use rand::{ + rng, + seq::{IndexedRandom, IteratorRandom}, +}; +use snafu::ResultExt; +use tokio::sync::{mpsc, oneshot}; + +use crate::topics::{TOPICS, Topic}; + +#[derive(Debug)] +pub enum Message { + GiveTopics { + existing: Vec<(usize, Topic)>, + desired: usize, + max_p: f64, + callback: oneshot::Sender>, + }, +} + +const OCCURRENCES_PATH: &str = "occurrences"; + +pub async fn actor(operator: Operator, mut messages: mpsc::Receiver) { + let mut occurrences = match operator.read(OCCURRENCES_PATH).await { + Ok(occurrences_bytes) => match from_bytes::>(&occurrences_bytes.to_bytes()) { + Ok(mut occurrences) => { + let new_topics = TOPICS.len().saturating_sub(occurrences.len()); + + for _ in 0..new_topics { + occurrences.push(0); + } + + occurrences + } + Err(error) => { + tracing::warn!( + ?error, + "could not deserialize existing record of occurrences" + ); + + let topics = TOPICS.len(); + vec![0; topics] + } + }, + Err(error) => { + tracing::warn!( + ?error, + "could not read existing record of occurrences; starting off with 0s" + ); + + let topics = TOPICS.len(); + vec![0; topics] + } + }; + + while let Some(message) = messages.recv().await { + match message { + Message::GiveTopics { + mut existing, + desired, + max_p, + callback, + } => { + let difference = (desired as isize) - (existing.len() as isize); + match usize::try_from(difference) { + Ok(_positive) => { + let exclude = + BTreeSet::from_iter(existing.into_iter().map(|(index, _topic)| index)); + + let candidates = + max_p_at_least_k_except(&occurrences, max_p, desired, &exclude); + + let chosen_ids_and_topics = candidates + .into_iter() + .map(|(index, _occurrence)| index) + .map(|index| (index, TOPICS[index])) + .choose_multiple(&mut rng(), desired); + + for (topic_id, _topic) in &chosen_ids_and_topics { + occurrences[*topic_id] = occurrences[*topic_id].saturating_add(1); + } + + // if the other end dropped, so what? + let _ = callback.send(chosen_ids_and_topics); + } + Err(_negative_error) => { + let chop_off = difference.abs() as usize; + + for _ in 0..chop_off { + let (topic_id, _occurrence) = existing.pop().unwrap(); + + occurrences[topic_id] = occurrences[topic_id].saturating_sub(1); + } + + // if the other end dropped, so what? + let _ = callback.send(existing); + } + } + + tracing::info!(?occurrences, "going to save"); + + match to_stdvec(&occurrences) { + Ok(bytes) => { + if let Err(error) = operator.write(OCCURRENCES_PATH, bytes).await { + tracing::error!(?error, "could not save the new record of occurrences"); + } + } + Err(error) => { + tracing::error!( + ?error, + "could not serialize the new record of occurrences to be able to save it" + ); + } + } + } + } + } +} + +/// May return a less than `min_k` elements if the list of `occurrences` isn't long enough to begin with +/// # Panics +/// Panics if `occurrences` is empty +fn max_p_at_least_k(occurrences: &[u64], max_p: f64, min_k: usize) -> Vec<(usize, u64)> { + let mut indices_and_occurrences = Vec::from_iter(occurrences.iter().copied().enumerate()); + indices_and_occurrences.sort_by_key(|&(_, occurrence)| occurrence); + + let &(_, least_occurring) = indices_and_occurrences.first().unwrap(); + let threshold = (least_occurring as f64) * max_p; + let threshold = threshold as u64; + + let candidates = indices_and_occurrences + .into_iter() + .enumerate() + .take_while(|&(k, (_index, occurrence))| k < min_k || occurrence <= threshold) + .map(|(_k, (index, occurrence))| (index, occurrence)); + + candidates.collect() +} + +/// May return a less than `min_k` elements if the list of `occurrences` isn't long enough after filtering out `excludes` +/// # Panics +/// Panics if `occurrences` is empty +fn max_p_at_least_k_except( + occurrences: &[u64], + max_p: f64, + min_k: usize, + exclude: &BTreeSet, +) -> Vec<(usize, u64)> { + let mut indices_and_occurrences = Vec::from_iter( + occurrences + .iter() + .copied() + .enumerate() + .filter(|(index, _occurrence)| !exclude.contains(index)), + ); + indices_and_occurrences.sort_by_key(|&(_, occurrence)| occurrence); + + let &(_, least_occurring) = indices_and_occurrences.first().unwrap(); + let threshold = (least_occurring as f64) * max_p; + let threshold = threshold as u64; + + let candidates = indices_and_occurrences + .into_iter() + .enumerate() + .take_while(|&(k, (_index, occurrence))| k < min_k || occurrence <= threshold) + .map(|(_k, (index, occurrence))| (index, occurrence)); + + candidates.collect() +} diff --git a/discord-bot/src/topics.rs b/discord-bot/src/topics.rs new file mode 100644 index 0000000..61c53dc --- /dev/null +++ b/discord-bot/src/topics.rs @@ -0,0 +1,88 @@ +#[derive(Debug, Clone, Copy)] +pub enum Topic { + Text(&'static str), + Player, + None, +} + +pub const TOPICS: [Topic; 79] = [ + Topic::None, + Topic::Player, + Topic::Text("adulthood"), + Topic::Text("appointments"), + Topic::Text("apps / programs"), + Topic::Text("art"), + Topic::Text("artificial intelligence"), + Topic::Text("beverages"), + Topic::Text("childhood"), + Topic::Text("clothes"), + Topic::Text("colors"), + Topic::Text("commitments"), + Topic::Text("commuting"), + Topic::Text("computers"), + Topic::Text("cooking"), + Topic::Text("countries"), + Topic::Text("COVID"), + Topic::Text("current events"), + Topic::Text("dreams"), + Topic::Text("family"), + Topic::Text("fitness"), + Topic::Text("flying"), + Topic::Text("friends"), + Topic::Text("fun facts"), + Topic::Text("geography"), + Topic::Text("gifts"), + Topic::Text("goals"), + Topic::Text("growing up"), + Topic::Text("history"), + Topic::Text("hobbies"), + Topic::Text("holidays"), + Topic::Text("hygiene"), + Topic::Text("jokes"), + Topic::Text("kids"), + Topic::Text("literature"), + Topic::Text("living spaces"), + Topic::Text("math"), + Topic::Text("memes"), + Topic::Text("memories"), + Topic::Text("movies"), + Topic::Text("music"), + Topic::Text("night"), + Topic::Text("nostalgia"), + Topic::Text("out to eat"), + Topic::Text("pets"), + Topic::Text("philosophy"), + Topic::Text("politics"), + Topic::Text("pranks"), + Topic::Text("professions"), + Topic::Text("purchases"), + Topic::Text("relationships"), + Topic::Text("relaxing"), + Topic::Text("religion"), + Topic::Text("respect"), + Topic::Text("retirement"), + Topic::Text("routines"), + Topic::Text("science"), + Topic::Text("school"), + Topic::Text("seasons"), + Topic::Text("sickness"), + Topic::Text("sleep"), + Topic::Text("smartphones"), + Topic::Text("social media"), + Topic::Text("socializing"), + Topic::Text("space"), + Topic::Text("spoilers"), + Topic::Text("sports"), + Topic::Text("super powers"), + Topic::Text("the future"), + Topic::Text("the internet"), + Topic::Text("tradition"), + Topic::Text("travel"), + Topic::Text("tv"), + Topic::Text("vacations"), + Topic::Text("video games"), + Topic::Text("weather"), + Topic::Text("wildlife"), + Topic::Text("work"), + Topic::Text("workspaces"), +];