feat: entire game (except the additional round I haven't designed yet, ability to change settings) implemented and ready for playtesting
This commit is contained in:
105
Cargo.lock
generated
105
Cargo.lock
generated
@@ -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"
|
||||
|
@@ -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"] }
|
||||
|
821
discord-bot/src/discord_formatting.rs
Normal file
821
discord-bot/src/discord_formatting.rs
Normal file
@@ -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<Id<UserMarker>>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<UserMarker>,
|
||||
players_in: impl Iterator<Item = Id<UserMarker>>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<CommandMarker>) -> 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: </respond:{respond_command_id}>"
|
||||
)
|
||||
}
|
||||
|
||||
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<CommandMarker>,
|
||||
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<Id<UserMarker>>,
|
||||
awarders: BTreeSet<Id<UserMarker>>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<Id<UserMarker>>,
|
||||
awarders: BTreeSet<Id<UserMarker>>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<Id<UserMarker>>,
|
||||
awarders: BTreeSet<Id<UserMarker>>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<u16, (usize, BTreeSet<Id<UserMarker>>)>,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<Id<UserMarker>>,
|
||||
score: u16,
|
||||
names: &BTreeMap<Id<UserMarker>, 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<CommandMarker>) -> 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: </create:{create_command_id}>"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn embed_for_thanks_and_how_to_create(create_command_id: Id<CommandMarker>) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.title(THANKS_FOR_PLAYING_HEADER)
|
||||
.description(how_to_create(create_command_id))
|
||||
.footer(EmbedFooterBuilder::new(DISCLAIMER_END).build())
|
||||
.validate()
|
||||
.unwrap()
|
||||
.build()
|
||||
}
|
1739
discord-bot/src/game.rs
Normal file
1739
discord-bot/src/game.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
discord-bot/src/lib.rs
Normal file
5
discord-bot/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod discord_formatting;
|
||||
pub mod game;
|
||||
pub mod lobbies;
|
||||
pub mod topic_picker;
|
||||
pub mod topics;
|
214
discord-bot/src/lobbies.rs
Normal file
214
discord-bot/src/lobbies.rs
Normal file
@@ -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<ChannelMarker>,
|
||||
initiation: Interaction,
|
||||
},
|
||||
Action {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
action: Action,
|
||||
},
|
||||
Destroy {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(discord_client, message_self, messages))]
|
||||
pub async fn actor(
|
||||
discord_client: twilight_http::Client,
|
||||
application_id: Id<ApplicationMarker>,
|
||||
create_command_id: Id<CommandMarker>,
|
||||
respond_command_id: Id<CommandMarker>,
|
||||
message_self: mpsc::Sender<LobbiesMessage>,
|
||||
mut messages: mpsc::Receiver<LobbiesMessage>,
|
||||
) {
|
||||
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();
|
||||
}
|
@@ -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(())
|
||||
}
|
||||
|
172
discord-bot/src/topic_picker.rs
Normal file
172
discord-bot/src/topic_picker.rs
Normal file
@@ -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<Vec<(usize, Topic)>>,
|
||||
},
|
||||
}
|
||||
|
||||
const OCCURRENCES_PATH: &str = "occurrences";
|
||||
|
||||
pub async fn actor(operator: Operator, mut messages: mpsc::Receiver<Message>) {
|
||||
let mut occurrences = match operator.read(OCCURRENCES_PATH).await {
|
||||
Ok(occurrences_bytes) => match from_bytes::<Vec<_>>(&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<usize>,
|
||||
) -> 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()
|
||||
}
|
88
discord-bot/src/topics.rs
Normal file
88
discord-bot/src/topics.rs
Normal file
@@ -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"),
|
||||
];
|
Reference in New Issue
Block a user