Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -22,11 +22,10 @@ mastodon-async = { version = "1.3.1", features = ["toml", "mt", "rustls-tls"], default-features = false } once_cell = "1.18.0" reqwest = { version = "0.11.22", default_features = false, features = ["rustls-tls"]} rand = "0.8.5" - # The following lines are optimising for a small binary, given a bot spends most of its time idling in RAM. [profile.release] strip = true opt-level = "z" Index: README.md ================================================================== --- README.md +++ README.md @@ -50,9 +50,10 @@ * The [fossil repository](https://modulus.isonomia.net/pravda.cgi) for the project. * If you must, email me at modulus at isonomia dot net. ## Change log. +* V 0.2.0: Spoof is implemented. * V 0.1.3: dice can be rolled with the dice command, accepting an ndm optional parameter. * V 0.1.2: exponential backoff system for cases where the streaming API fails. Assorted typographical corrections. * V 0.1.1: set a loop around the toot streaming in case it errors out. * V 0.1.0: initial release. Index: src/dice.rs ================================================================== --- src/dice.rs +++ src/dice.rs @@ -1,22 +1,32 @@ -use crate::game::{Command, Game, Reply}; +use crate::game::{Command, Game, GameEvent, Reply}; use crate::{Lazy, Regex}; use rand::Rng; pub struct Dice; impl Game for Dice { - fn new() -> Self { - Dice + fn new( + _: tokio::sync::mpsc::UnboundedSender, + _: tokio_util::sync::CancellationToken, + ) -> Self { + Dice } fn next(&mut self, m: &Command) -> Reply { let mut r = Reply::new(); static DICE: Lazy = Lazy::new(|| { - Regex::new(r"^(?:dice|dado)(?: (\d+)(?:d(\d+))?)?$").expect("Problem building dice regexp.") + Regex::new(r"^(?:dice|dado)(?: (\d+)(?:d(\d+))?)?$") + .expect("Problem building dice regexp.") }); let mut rng = rand::thread_rng(); let mut gen = |n1, n2| rng.gen_range(n1..=n2); + let m = match m { + Command::PlayerCommand(c) => c, + Command::InternalCommand(_) => { + return r; + } + }; if let Some(caps) = DICE.captures(&m.content) { let l = caps.iter().skip(1).filter(|c| c.is_some()).count(); // println!("{}: {:?}", l, caps); match l { Index: src/game.rs ================================================================== --- src/game.rs +++ src/game.rs @@ -4,11 +4,14 @@ /// Games are represented as state machines. /// The game trait. pub trait Game: Send { /// A static method to create a new game. - fn new() -> Self + fn new( + c: tokio::sync::mpsc::UnboundedSender, + token: tokio_util::sync::CancellationToken, + ) -> Self where Self: Sized; /// next is the state machine. It receives a Command and gives back a Reply. fn next(&mut self, m: &Command) -> Reply; } @@ -29,10 +32,35 @@ pub fn quiet(&mut self) { self.1 = Visibility::Direct; } } -/// A command. This is what we receive at next(). -pub struct Command { +/// A command from a player: sender and content. +pub struct PlayerCommand { pub sender: String, pub content: String, } + +/// An internal command: contains the match it is for, and a string. +pub struct InternalCommand { + pub name: String, + pub match_index: usize, + pub command: String, +} + +/// A game command. +pub enum Command<'a> { + /// It may be a PlayerCommand. + PlayerCommand(&'a PlayerCommand), + /// Or an internal command to a specific match. + InternalCommand(InternalCommand), +} + +/// An event. +pub enum GameEvent { + /// A response to send. + Reply(Reply), + /// Or an Internal Command.. + Step(InternalCommand), + /// Or a fedi notification. + Notification(crate::Notification), +} Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -1,7 +1,7 @@ use futures_util::TryStreamExt; -use game::{Command, Game}; +use game::{Command, Game, GameEvent, PlayerCommand}; use mastodon_async::{ entities::notification::NotificationType, helpers::{cli, toml}, prelude::*, Language, Result, StatusBuilder, @@ -18,10 +18,11 @@ use tokio_util::sync::CancellationToken; mod dice; mod game; mod rps; +mod spoof; /// Admin account. It can send administrative commands. const ADMIN: &str = "modulux@node.isonomia.net"; /// Quit command. @@ -55,19 +56,22 @@ #[tokio::main] async fn main() -> Result<()> { let token = CancellationToken::new(); let token2 = token.clone(); + // This channel sends events from the fedi API and game events to the main event loop. + let (cs, mut cr) = mpsc::unbounded_channel(); + let spoof = spoof::Spoof::new(cs.clone(), token.clone()); + let dice = dice::Dice::new(cs.clone(), token.clone()); + // g is a vector of games. - let mut g: Vec> = vec![Box::new(rps::Rps::new())]; + let mut g: Vec> = vec![Box::new(rps::Rps::new(cs.clone(), token.clone()))]; // We add Rock Paper Scissors.. // If you write another game you need to create a file, use mod, and add it like this. - // g.push(Box::new(rps::Rps::new())); - g.push(Box::new(dice::Dice::new())); - - let (cs, mut cr) = mpsc::unbounded_channel(); + g.push(Box::new(spoof)); + g.push(Box::new(dice)); let web_client = ClientBuilder::new() .user_agent("Werewolf 0.1") .build() .expect("Problem building Reqwest client."); @@ -76,16 +80,18 @@ Mastodon::new(web_client, data) } else { register().await? }; + // We need one mastodon object for the stream and another to post statuses through. let m = mastodon.clone(); + // Event loop, runs on another thread. tokio::spawn(async move { println!("Initiating main event loop."); while let Some(event) = cr.recv().await { match event { - Event::Notification(n) if n.notification_type == NotificationType::Mention => { + GameEvent::Notification(n) if n.notification_type == NotificationType::Mention => { let sender = n.account.acct; // Time to check if we got a quit message. let s = n .status .expect("Event is a notification but does not have a status."); @@ -95,16 +101,15 @@ token.cancel(); break; } print_status(&s).await; // Treat the toot like a command. - let id = s.id; - let comm = Command { sender, content }; + let comm = PlayerCommand { sender, content }; // We iterate on each game. for i in g.iter_mut() { // Asking it if we must reply. - let r = i.next(&comm); + let r = i.next(&Command::PlayerCommand(&comm)); // And for each reply. // Take the visibility. let v = r.1; for j in r.0 { // We send it. @@ -111,11 +116,10 @@ println!("Sent: {}", j); let status = StatusBuilder::new() .status(j.to_string()) .language(Language::Eng) .visibility(v) - .in_reply_to(id.to_string()) .build(); let _ = post_status(&m, status.expect("Problem posting a status.")).await; } } @@ -141,12 +145,12 @@ } _ => { sleep(Duration::from_secs(2u64.pow(min(MAX_BL, bl).into()))).await; if bl < MAX_BL.into() { bl += 1; + println!("Backing off level now {}.", bl); } - println!("Backing off level now {}.", bl); continue; } }; select! { @@ -156,26 +160,29 @@ break 'stream; } _ = stream.try_for_each(|(event, _)| async { - cs.send(event).expect("Problem sending event through channel."); + if let Event::Notification(n) = event { + cs.send(GameEvent::Notification(n)).expect("Problem sending event through channel."); + } Ok(()) }) => { - if bl < MAX_BL { - bl += 1; - println!("Streaming terminated. Backing off level now {}", bl); - }} - None = async { + if bl < MAX_BL { bl += 1; } + println!("Streaming terminated. Backing off level now {}", bl); + } + Some(()) = async { loop { sleep(Duration::from_secs(30)).await; if bl > 0 { bl -= 1; - println!("Backing off level now {}.", bl);} + println!("Backing off level now {}.", bl); } - Some(()) + // Return value for the type checker. + if false { return None } + } } => {} }; println!("Streaming terminated. Loop: {}. Backing off: {}", l, bl); Index: src/rps.rs ================================================================== --- src/rps.rs +++ src/rps.rs @@ -1,37 +1,42 @@ -use crate::game::{Command, Game, Reply}; -use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use crate::game::{Command, Game, GameEvent, Reply}; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, +}; /// Rock, Paper, Scissors. /// A match. Considering to move this to Game. struct Match { state: HashMap>, } impl Match { + /// Create a new match from the set of players in the lobby. fn new(h: &HashSet) -> Self { let mut n = HashMap::new(); for key in h { n.insert(key.clone(), None); } Match { state: n } } + /// Determine if the game is ready for solution. fn is_ready(&self) -> bool { self.state.values().all(|v| v.is_some()) } + /// Return the opponent of a player in an ongoing match. fn opponent(&self, s: &String) -> String { self.state .keys() .find(|k| k != &s) .expect("Getting opponent, player name not in the set.") .to_string() } - // Solves the game. MUST never call this method if there aren't two plays made. + /// Solves the game. MUST never call this method if there aren't two plays made. fn solve(&self) -> Reply { // Our reply. let mut r = Reply::new(); // Players. let mut p = Vec::new(); @@ -79,19 +84,22 @@ pub struct Rps { /// A HashSet with the players waiting to play as account strings. lobby: HashSet, /// capacity determines how many people a match contains. capacity: u8, - // A vector of ongoing matches. + /// A vector of ongoing matches. matches: Vec, - // HashSet indicating for each player which match they are in. + /// HashSet indicating for each player which match they are in. players: HashMap, } impl Game for Rps { /// Creation of a new and empty Rps game structure. - fn new() -> Self { + fn new( + c: tokio::sync::mpsc::UnboundedSender, + token: tokio_util::sync::CancellationToken, + ) -> Self { Rps { lobby: HashSet::new(), capacity: 2, matches: Vec::new(), players: HashMap::new(), @@ -99,10 +107,17 @@ } /// State machine that accepts a command, changes state and delivers replies if required. fn next(&mut self, m: &Command) -> Reply { let mut r = Reply::new(); + let m = match m { + Command::PlayerCommand(c) => c, + Command::InternalCommand(_) => { + return r; + } + }; + // The entire state depends on two factors: are we waiting to join a game, and are we playing a game? // It is possible for both to be false, and either one to be true. // If both are true, this is an error. let waiting = self.lobby.contains(&m.sender); let playing = self.players.contains_key(&m.sender); @@ -119,15 +134,10 @@ // At most, one of these conditions can hold. assert!(!(joining && quitting)); assert!(!(joining && choice.is_some())); assert!(!(quitting && choice.is_some())); // At this point we have all necessary information to match. - /* println!( - "{}, {}, {:?}, {}, {}.", - joining, quitting, choice, playing, waiting - ); - */ match (joining, quitting, choice, playing, waiting) { // We don't bother with the impossible cases that are already excluded by assertion. // Let's start with joining the game. // There are 3 cases we need care about: // We're joining, not playing and not waiting. @@ -276,20 +286,20 @@ r } } -// Valid plays. +/// Valid plays. #[derive(PartialEq, Debug)] enum Play { Rock, Paper, Scissors, } impl Play { - // Silly sound effects. + /// Silly sound effects. fn noise(&self) -> String { match self { Play::Rock => "CRUNCH!", Play::Scissors => "SNIP!", Play::Paper => "CRUMPLE!", @@ -324,38 +334,56 @@ } /// Tests. #[cfg(test)] mod test { - use crate::game::{Command, Game}; + use crate::game::{Command, Game, PlayerCommand, Reply}; use crate::rps::Rps; + + fn command(g: &mut dyn Game, m: (String, String)) -> Reply { + let (sender, content) = m; + let p = PlayerCommand { sender, content }; + let r = g.next(&Command::PlayerCommand(&p)); + r + } #[test] fn test_new() { - let g = Rps::new(); + let g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); assert_eq!(g.capacity, 2); assert_eq!(g.lobby.len(), 0); } #[test] fn test_nonsense() { - let c: Command = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "nonsense".to_string(), - }; - let mut g = Rps::new(); - assert!(g.next(&c).0.is_empty()); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "nonsense".to_string(), + ), + ); + assert!(r.0.is_empty()); } #[test] fn test_first_join_game() { - let mut g = Rps::new(); - let c = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - let r = g.next(&c); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let r = command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); assert_eq!(r.0.len(), 1, "Incorrect number of replies: {}.", r.0.len()); assert_eq!(r.0[0], "@modulux@node.isonomia.net You've asked to join a game of Rock, Paper, Scissors. As soon as someone else wants to play, I'll send you a message so you can tell me your choice.".to_string(), "Incorrect reply message: {}.", r.0[0]); assert_eq!( g.capacity, 1, "Capacity in lobby should be 1, is {}.", @@ -363,17 +391,22 @@ ); } #[test] fn test_join_game_twice() { - let mut g = Rps::new(); - let c = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - g.next(&c); - let r = g.next(&c); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + let r = command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:}.", r.0.len()); assert_eq!(r.0[0], "@modulux@node.isonomia.net You're already waiting for a game of Rock, Paper, Scissors. Be patient.".to_string(), "Incorrect reply message: {}.", r.0[0]); assert_eq!( g.capacity, 1, "Capacity in lobby should be 1, is {}.", @@ -381,21 +414,18 @@ ); } #[test] fn test_join_game_complete() { - let mut g = Rps::new(); - let c1 = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - g.next(&c1); - let c2 = Command { - sender: "modulux2@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - let r = g.next(&c2); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string()); + command(&mut g, c1); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string()); + let r = command(&mut g, c2); assert_eq!(r.0.len(), 2, "Incorrect number of replies: {}.", r.0.len()); assert!(r.0.contains(&"@modulux@node.isonomia.net Got a partner! Your opponent is modulux2@node.isonomia.net Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply."); assert!(r.0.contains(&"@modulux2@node.isonomia.net Got a partner! Your opponent is modulux@node.isonomia.net @@ -403,27 +433,21 @@ Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply."); } #[test] fn test_play_twice() { - let mut g = Rps::new(); - let c1 = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - let c2 = Command { - sender: "modulux2@node.isonomia.net".to_string(), - content: "rps".to_string(), - }; - let c3 = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rock".to_string(), - }; - g.next(&c1); - g.next(&c2); - g.next(&c3); - let r = g.next(&c3); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string()); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string()); + let c3 = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + command(&mut g, c1); + command(&mut g, c2); + command(&mut g, c3.clone()); + let r = command(&mut g, c3); assert_eq!( r.0.len(), 1, "Incorrect number of replies. Reply: {:#?}.", r @@ -434,54 +458,60 @@ If you get bored, you can send me *cancelrps* to cancel the game.", "Incorrect reply: {}.", r.0[0]); } #[test] fn test_play_too_early() { - let mut g = Rps::new(); - let c = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rock".to_string(), - }; - let r = g.next(&c); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + let r = command(&mut g, c); assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); } #[test] fn twice_early_play() { let mut r; - let mut g = Rps::new(); - let c1 = Command { - sender: "modulux@node.isonomia.net".to_string(), - content: "rock".to_string(), - }; - let c2 = Command { - sender: "modulux2@node.isonomia.net".to_string(), - content: "rock".to_string(), - }; - r = g.next(&c1); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rock".to_string()); + r = command(&mut g, c1); assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); - r = g.next(&c2); + r = command(&mut g, c2); assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); assert_eq!(r.0[0], "@modulux2@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); } - fn command(sender: &str, content: &str) -> Command { - Command { - sender: sender.to_string(), - content: content.to_string(), - } - } #[test] fn test_join_full_then_cancel() { let r; - let mut g = Rps::new(); - g.next(&command("modulux@node.isonomia.net", "rps")); - g.next(&command("modulux2@node.isonomia.net", "rps")); - r = g.next(&command("modulux@node.isonomia.net", "cancelrps")); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux2@node.isonomia.net".to_string(), "rps".to_string()), + ); + r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "cancelrps".to_string(), + ), + ); assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:?}", r); assert_eq!( r.0[0], "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net @@ -492,15 +522,33 @@ } #[test] fn test_join_full_play_then_cancel() { let r; - let mut g = Rps::new(); - g.next(&command("modulux@node.isonomia.net", "rps")); - g.next(&command("modulux2@node.isonomia.net", "rps")); - g.next(&command("modulux@node.isonomia.net", "rock")); - r = g.next(&command("modulux@node.isonomia.net", "cancelrps")); + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux2@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rock".to_string()), + ); + r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "cancelrps".to_string(), + ), + ); assert_eq!(r.0.len(), 1, "Incorrect length. {:?}", r); assert_eq!( r.0[0], "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net ADDED src/spoof.rs Index: src/spoof.rs ================================================================== --- /dev/null +++ src/spoof.rs @@ -0,0 +1,368 @@ +use crate::{ + game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}, + sleep, Duration, Lazy, Regex, +}; +use std::collections::{HashMap, HashSet}; +/// Spoof. + +/// A match. Considering to move this to Game. +/// TODO: think about locales. +struct Match { + state: HashMap>, +} + +impl Match { + fn new(h: &HashSet) -> Self { + let mut n = HashMap::new(); + for key in h { + n.insert(key.clone(), None); + } + Match { state: n } + } + + fn is_ready(&self) -> bool { + self.state.values().all(|v| v.is_some()) + } +} + +/// Data structure of the game. +pub struct Spoof { + /// name of the game. + name: String, + /// A HashSet with the players waiting to play as account strings. + lobby: HashSet, + /// capacity determines how many people a match contains. + capacity: u8, + /// A vector of ongoing matches. + matches: Vec, + /// HashSet indicating for each player which match they are in. + players: HashMap, + /// This channel is the way for the game to ask the main event loop to send it an internal command. + channel: tokio::sync::mpsc::UnboundedSender, + /// Cancellation token. + token: tokio_util::sync::CancellationToken, +} + +impl Spoof { + /// Handling a player command. + fn player(&mut self, m: &PlayerCommand) -> Reply { + // Our reply. + let mut r = Reply::new(); + // Are we in the lobby? + let waiting = self.lobby.contains(&m.sender); + // Are we playingg? + let playing = self.players.contains_key(&m.sender); + // Spoof has three commands: spoof to join, keep plus a number to determine how many coins you keep in your + // hand, and guess plus a number to make your guess. + // Split command into words: + let words: Vec<_> = m.content.split_whitespace().collect(); + // match commands. + match words[..] { + ["spoof"] => { + if waiting { + r.push(format!( + "@{} You're already waiting for a game of spoof. Be patient.", + &m.sender, + )); + } else if playing { + r.push(format!( + "@{} You're already playing a game of spoof.", + &m.sender + )); + } else { + if self.capacity > 1 { + r.push(format!("@{} You are now waiting for a game of spoof. The game will begin when there are 10 players, or when there are at least 3 players and nobody has joined in 5 minutes.", &m.sender)); + self.lobby.insert(m.sender.clone()); + self.capacity -= 1; + } else { + // Lobby's full. Game must start. + self.lobby.insert(m.sender.clone()); + let players = self.lobby.clone(); + let this_match = Match::new(&players); + self.lobby = HashSet::new(); + self.capacity = 10; + self.matches.push(this_match); + let n = self.matches.len() - 1; + for i in players { + self.players.insert(i, n); + } + // Send start command. + self.channel + .send(GameEvent::Step(InternalCommand { + name: self.name.clone(), + match_index: n, + command: "start".to_string(), + })) + .expect("Problem sending event through channel."); + } + } + } + _ => {} + } + r + } + + /// Handling an internal command. + fn internal(&mut self, m: &InternalCommand) -> Reply { + let mut r = Reply::new(); + if m.name == self.name {} + r + } +} + +impl Game for Spoof { + /// Creation of a new and empty Spoof game structure. + fn new( + c: tokio::sync::mpsc::UnboundedSender, + token: tokio_util::sync::CancellationToken, + ) -> Self { + Spoof { + name: "spoof".to_string(), + lobby: HashSet::new(), + capacity: 10, + matches: Vec::new(), + players: HashMap::new(), + channel: c, + token: token, + } + } + + /// State machine that accepts a command, changes state and delivers replies if required. + fn next(&mut self, m: &Command) -> Reply { + let mut r = Reply::new(); + r = match m { + Command::PlayerCommand(p) => self.player(p), + Command::InternalCommand(i) => self.internal(i), + }; + r + } +} + +/// Play is a guess, or a failure to guess. +struct Play(Option); + +/// Tests. +#[cfg(test)] +mod test { + use crate::game::{Command, Game, PlayerCommand, Reply}; + use crate::spoof::Spoof; + + fn command(g: &mut dyn Game, m: (String, String)) -> Reply { + let (sender, content) = m; + let p = PlayerCommand { sender, content }; + let r = g.next(&Command::PlayerCommand(&p)); + r + } + + #[test] + fn test_new() { + let g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + assert_eq!(g.capacity, 10); + assert_eq!(g.lobby.len(), 0); + } + + #[test] + fn test_nonsense() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "nonsense".to_string(), + ), + ); + assert!(r.0.is_empty()); + } + + #[test] + fn test_first_join_game() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let r = command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "spoof".to_string()), + ); + assert_eq!(r.0.len(), 1, "Incorrect number of replies: {}.", r.0.len()); + assert_eq!(r.0[0], "@modulux@node.isonomia.net You are now waiting for a game of spoof. The game will begin when there are 10 players, or when there are at least 3 players and nobody has joined in 5 minutes.".to_string(), "Incorrect reply message: {}.", r.0[0]); + assert_eq!( + g.capacity, 9, + "Capacity in lobby should be 1, is {}.", + g.capacity + ); + } + + #[test] + fn test_join_game_twice() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + let r = command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:}.", r.0.len()); + assert_eq!(r.0[0], "@modulux@node.isonomia.net You're already waiting for a game of Rock, Paper, Scissors. Be patient.".to_string(), "Incorrect reply message: {}.", r.0[0]); + assert_eq!( + g.capacity, 1, + "Capacity in lobby should be 1, is {}.", + g.capacity + ); + } + + #[test] + fn test_join_game_complete() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string()); + command(&mut g, c1); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string()); + let r = command(&mut g, c2); + assert_eq!(r.0.len(), 2, "Incorrect number of replies: {}.", r.0.len()); + assert!(r.0.contains(&"@modulux@node.isonomia.net Got a partner! Your opponent is modulux2@node.isonomia.net + +Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply."); + assert!(r.0.contains(&"@modulux2@node.isonomia.net Got a partner! Your opponent is modulux@node.isonomia.net + +Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply."); + } + + #[test] + fn test_play_twice() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string()); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string()); + let c3 = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + command(&mut g, c1); + command(&mut g, c2); + command(&mut g, c3.clone()); + let r = command(&mut g, c3); + assert_eq!( + r.0.len(), + 1, + "Incorrect number of replies. Reply: {:#?}.", + r + ); + + assert_eq!(r.0[0], "@modulux@node.isonomia.net You already sent me your choice. You need to wait for modulux2@node.isonomia.net + +If you get bored, you can send me *cancelrps* to cancel the game.", "Incorrect reply: {}.", r.0[0]); + } + + #[test] + fn test_play_too_early() { + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + let r = command(&mut g, c); + assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); + assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); + } + + #[test] + fn twice_early_play() { + let mut r; + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let c1 = ("modulux@node.isonomia.net".to_string(), "rock".to_string()); + let c2 = ("modulux2@node.isonomia.net".to_string(), "rock".to_string()); + r = command(&mut g, c1); + assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); + assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); + + r = command(&mut g, c2); + assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r); + assert_eq!(r.0[0], "@modulux2@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r); + } + + #[test] + fn test_join_full_then_cancel() { + let r; + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux2@node.isonomia.net".to_string(), "rps".to_string()), + ); + r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "cancelrps".to_string(), + ), + ); + assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:?}", r); + assert_eq!( + r.0[0], + "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net + +You're both welcome to play again any time. Use *rps* to start a new match.", + "Incorrect response: {}", + r.0[0] + ); + } + + #[test] + fn test_join_full_play_then_cancel() { + let r; + let mut g = Spoof::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux2@node.isonomia.net".to_string(), "rps".to_string()), + ); + command( + &mut g, + ("modulux@node.isonomia.net".to_string(), "rock".to_string()), + ); + r = command( + &mut g, + ( + "modulux@node.isonomia.net".to_string(), + "cancelrps".to_string(), + ), + ); + assert_eq!(r.0.len(), 1, "Incorrect length. {:?}", r); + assert_eq!( + r.0[0], + "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net + +You're both welcome to play again any time. Use *rps* to start a new match.", + "Incorrect response. {}.", + r.0[0] + ); + } +}