ADDED AGENTS.md Index: AGENTS.md ================================================================== --- /dev/null +++ AGENTS.md @@ -0,0 +1,33 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Root cargo workspace lives at `Cargo.toml`; shared library code sits in `src/`. +- Game logic is split per module (`src/rps.rs`, `src/spoof.rs`, `src/soviet.rs`, etc.) and all implement the `Game` trait in `src/game.rs`. +- Binary entry points reside in `src/main.rs` (production bot) and `src/bin/game_cli.rs` (CLI harness). +- Inline unit tests live next to their modules under `#[cfg(test)]`; no standalone `tests/` directory is used currently. +- Persistence data (`game_state.toml`) is emitted at runtime beside the binary; keep it out of version control. + +## Build, Test, and Development Commands +- `cargo check` — Fast validation of current changes without generating binaries. +- `cargo test` — Execute all unit and integration tests; run before every commit. +- `cargo run --bin pravda` — Launch the async bot (requires network credentials and Mastodon config). +- `cargo run --bin game_cli` — Start the offline game CLI for rapid manual experimentation. +- `cargo fmt` / `cargo clippy` — Apply Rustfmt formatting and run static analysis; fix or suppress warnings before review. + +## Coding Style & Naming Conventions +- Follow idiomatic Rust: four-space indentation, `snake_case` for functions/variables, `CamelCase` for types. +- Derive serde traits when persisting state; favor `Value::try_from` over deprecated helpers. +- Keep modules focused on one game; common utilities belong in `src/game.rs` or shared helpers. +- Document surprising control flow with short comments; avoid redundant commentary on obvious code paths. + +## Testing Guidelines +- Tests use Rust’s built-in framework via `#[test]` and `tokio::test`; mirror existing naming (`test_*` or descriptive phrases). +- When adding async behaviour, prefer deterministic unit tests over integration tests. +- Reproduce concurrency scenarios with helper functions already present in the module (for example `command()` in `rps::test`). +- Ensure persisted state types implement `Serialize`/`Deserialize` and are covered by round-trip tests when changed. + +## Commit & Pull Request Guidelines +- Keep commits atomic; reference the affected game in the message (e.g. `Update soviet persistence handling`). +- Use Fossil for version control: `FOSSIL_HOME=.fossil-home fossil commit --nosync --nosign -m ""`. +- Include test evidence in PR descriptions (`cargo test`, manual CLI run). Attach reproduction steps for bug fixes. +- Link related issues or tickets, and call out configuration changes (OAuth keys, state-file schema) explicitly to alert deployers. Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -20,12 +20,17 @@ regex = { default-features = false, version = "1.10.0" } tokio-util = "0.7.9" 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"]} +reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls"]} rand = "0.8.5" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +tokio-tungstenite = { version = "0.21", default-features = false, features = ["rustls-tls-webpki-roots", "connect"] } +url = "2.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 @@ -1,59 +1,91 @@ ## Pravda: social games for a social network. -This crate is a functional bot that you can use to connect to a mastodon server (or one providing a similar API) in order to run social games. For now it can be used to play *Rock, Paper, Scissors*, or roll dice. The code is written modularly so that it is relatively easy to add new games. - -The Game trait defines the behaviour for a game, involving an associated method *new* for setup, and a method *next* which receives user toots and is responsible to advance the state of the game. Every game receives every message and must be able to discard anything that is irrelevant to it. - -There are some constants to shut down the bot remotely with a toot. *ADMIN* contains the administrative account, *QUIT* contains the quit message which must be sent as a mention to the bot, and *INSTANCE* contains the URL of the instance to connect to. At the moment, it uses my own fedi account by default, and the message *!QUIT!* but you should customise this if you deploy. The default instance is botsin.space, an appropriate instance for bots, but you should change this if you want to run it somewhere else. - -Bear in mind you must have a valid account on the instance before you attempt to authorise the bot. - -### First run. - -Decide where you want to execute the game, and make sure you have write permissions. The game will connect to an instance and attempt to establish an OAuth flow, asking you to visit a link and authorise the application with your mastodon/fedi account. - -Once this is done, the information is written into a toml file, which *should* remain private, as it contains a token that allows for almost full control of the account it's authorised under. - -On subsequent runs, if this file is encountered, the information will be read from it and the connection will be realised without human intervention. - -### Limitations. - -It is not possible to connect this client to GoTosocial for now. mastodon-async does not use the websocket endpoint to stream from the fedi, but the SSE endpoint instead. GoToSocial only implements the websocket endpoint for streaming operations. - -Naturally, it supports both HTTP and HTTPS, through the use of rustls-tls by default. You can change to OpenSSL by modifying the features in Cargo.toml. - -### Future ambitions. - -I'm interested in implementing any sort of social game, by which I understand games which involve social interaction, deception, prediction, or the like. In particular, I am interested in implementing the following games: - -* [Spoof](https://en.wikipedia.org/wiki/Spoof_(game\)) (in progress on the spoof branch). -* [Prisoner's dilemma](https://en.wikipedia.org/wiki/Prisoner%27s_dilemma). -* [Centipede game](https://en.wikipedia.org/wiki/Centipede_game). -* [Public goods game](https://en.wikipedia.org/wiki/Public_goods_game). -* [Traveller's dilemma](https://en.wikipedia.org/wiki/Traveler%27s_dilemma). -* [Ultimatum game](https://en.wikipedia.org/wiki/Ultimatum_game). -* [Dictator game](https://en.wikipedia.org/wiki/Dictator_game) -* [Mafia](https://en.wikipedia.org/wiki/Mafia_(party_game\)). -* Unique lowest bid. -* Guess 2/3 the average. -* Predict the judgement. - -Some of these games need more interesting names. - -It would be nice to run a nomic on the fedi, but that is a lot more than a bot can do by itself. - -## Bug reports, feature requests, code contributions and other feedback. - -If you want to tell me something about the crate, the best ways are: - -* My fedi account, [@modulux@node.isonomia.net](https://node.isonomia.net/@modulux). -* 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. +Pravda is a Mastodon bot that runs social deduction and party games. It +connects to any Mastodon-compatible instance, listens for mentions, and routes +commands to a catalogue of games implemented as pluggable state machines. + +Out of the box the bot supports: + +- **Rock, Paper, Scissors** – quick duels with private moves and public results. +- **Spoof** – hidden-coin guessing with timed reminders. +- **Dice** – flexible NdM style dice rolling (`dice`, `dice 3`, `dice 2d12`). +- **Unique Lowest Bid** – sealed-bid auction where the lowest unique bid wins. +- **Two Thirds Average** – players try to guess two-thirds of the group average. +- **Soviet Mafia** – a fully themed social deduction game with roles such as + workers, saboteurs, commissars, spies, and more. + +All games implement the shared `Game` trait (`src/game.rs`), receiving every +incoming command and returning a `Reply` that includes text plus visibility +(a.k.a. public vs. DM). The runtime fans out commands to each game, and the +game decides whether to react or ignore the input. This makes it easy to add +new games without touching the bot core; just register them in +`src/catalog.rs` so they are constructed at startup. + +### Getting Started + +1. Install Rust and clone this repository. +2. Run `cargo run --bin game_cli` to exercise the games locally via stdin. + The CLI harness accepts input in the form `acct@example.com: command` and + prints bot responses, which is helpful for development. +3. To connect to Mastodon, ensure you have an account on your target instance + and execute `cargo run --bin pravda`. The first run walks you through the + OAuth flow and stores credentials in `mastodon-data.toml` (keep it private). +4. Update `ADMIN`, `QUIT`, and `INSTANCE` in `src/main.rs` to match your desired + deployment values before going live. + +### Development Workflow + +- Run `cargo fmt` and `cargo clippy --all-targets` to keep formatting and + lints tidy. +- Execute `cargo test` before committing. Unit tests sit next to the modules + they exercise. +- Use `cargo run --bin game_cli` for fast manual testing; most command flows can + be simulated locally without touching the network. + +### Security & Operational Notes + +- Outbound statuses are normalised and truncated before posting; nevertheless, + run the bot from a dedicated account with scoped tokens (read:notifications, + write:statuses, follow). +- The bot ignores mentions originating from itself to avoid event loops. +- When adding new games, sanitise any user-controlled text before including it + in replies. + +### Extending Pravda + +To create a new game: + +1. Add a module under `src/` that implements `Game`. +2. Register it in `src/catalog.rs::default_games` so both the bot and CLI know + about it. +3. Emit appropriate replies via the provided `Reply` type. If you need timers, + schedule your own tasks and cancel them when the game ends (see + `src/soviet.rs` for an example). +4. Document the commands in this README so players know how to interact. + +Unit tests live alongside their games. Existing tests illustrate how to drive +command sequences without Mastodon. + +### Planned Work + +- Balance tuning for the Soviet Mafia roles and better test coverage for + probabilistic flows. +- Additional social experiments such as Prisoner's Dilemma, Centipede, and + Nomic-style rule systems. + +### Support & Feedback + +- Mastodon: [@modulux@node.isonomia.net](https://node.isonomia.net/@modulux) +- Fossil repo: +- Email: modulus at isonomia dot net + +### Changelog + +* Unreleased + * Added Unique Lowest Bid, Two Thirds Average, and Soviet Mafia games. + * Hardened outbound post handling and added a shared game manager. +* V0.2.0 – Spoof +* V0.1.3 – Dice roll command (`dice`, `dice NdM`) +* V0.1.2 – Streaming backoff and assorted fixes +* V0.1.1 – Wrapped streaming in retry loop +* V0.1.0 – Initial release ADDED src/bin/game_cli.rs Index: src/bin/game_cli.rs ================================================================== --- /dev/null +++ src/bin/game_cli.rs @@ -0,0 +1,122 @@ +use mastodon_async::Visibility; +use pravda::game::{Command, Game, GameEvent, PlayerCommand, Reply}; +use pravda::{catalog, CancellationToken}; +use std::error::Error; +use std::io::{self, BufRead}; +use std::thread; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let token = CancellationToken::new(); + let (sender, mut receiver) = mpsc::unbounded_channel(); + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); + thread::spawn(move || { + let stdin = io::stdin(); + let mut handle = stdin.lock(); + let mut buffer = String::new(); + loop { + buffer.clear(); + match handle.read_line(&mut buffer) { + Ok(0) => break, + Ok(_) => { + let line = buffer.trim_end_matches(['\n', '\r']).to_string(); + if input_tx.send(line).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + let mut games: Vec> = catalog::default_games(&sender, &token); + + println!("Pravda CLI test harness"); + println!("Enter ': '. Commands: rps, spoof, ulb, twothirds, dice, etc. Type 'quit' to exit."); + + loop { + tokio::select! { + event = receiver.recv() => { + if let Some(event) = event { + handle_event(event, &mut games); + } else { + break; + } + } + input = input_rx.recv() => { + if let Some(input) = input { + if input.trim().eq_ignore_ascii_case("quit") { + break; + } + if let Some((sender_id, content)) = input.split_once(':') { + let sender = sender_id.trim().to_string(); + if sender.is_empty() { + println!("Please provide a sender before the colon."); + continue; + } + let content = content.trim().to_lowercase(); + if content.is_empty() { + println!("Please provide a command after the colon."); + continue; + } + if content == "help" { + let mut reply = Reply::new(); + reply.push( + "Available games: rps, spoof, dice, unique lowest bid (ulb), two-thirds average, soviet.". + to_string(), + ); + reply.push( + "Join with commands like 'spoof', 'rps', 'ulb', 'twothirds', 'dice', or 'soviet'.". + to_string(), + ); + reply.push( + "For detailed rules, ask ' help' (for example 'soviet help').".to_string(), + ); + display_reply(&sender, reply); + continue; + } + let player_command = PlayerCommand { sender: sender.clone(), content }; + for game in games.iter_mut() { + let reply = game.next(&Command::PlayerCommand(&player_command)); + display_reply(&sender, reply); + } + } else { + println!("Invalid input. Use ': '."); + } + } else { + break; + } + } + } + } + + println!("Goodbye"); + Ok(()) +} + +fn handle_event(event: GameEvent, games: &mut [Box]) { + match event { + GameEvent::Reply(reply) => display_reply("system", reply), + GameEvent::Step(internal) => { + for game in games.iter_mut() { + let reply = game.next(&Command::InternalCommand(internal.clone())); + display_reply("system", reply); + } + } + GameEvent::Persist => {} + GameEvent::Notification(_) => {} + } +} + +fn display_reply(source: &str, reply: Reply) { + if reply.0.is_empty() { + return; + } + let visibility = reply.1; + for message in reply.0 { + match visibility { + Visibility::Direct => println!("[DM][{}] {}", source, message), + _ => println!("[{}] {}", source, message), + } + } +} ADDED src/catalog.rs Index: src/catalog.rs ================================================================== --- /dev/null +++ src/catalog.rs @@ -0,0 +1,203 @@ +use crate::game::{Game, GameEvent}; +use crate::{dice, rps, soviet, spoof, twothirds, unique}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// Construct the default catalogue of games wired into the bot. +/// +/// Every game receives a cloned sender and cancellation token so they can +/// schedule internal events and react to shutdown signals without touching the +/// caller's resources. +pub fn default_games( + sender: &mpsc::UnboundedSender, + token: &CancellationToken, +) -> Vec> { + vec![ + Box::new(rps::Rps::new(sender.clone(), token.clone())), + Box::new(spoof::Spoof::new(sender.clone(), token.clone())), + Box::new(dice::Dice::new(sender.clone(), token.clone())), + Box::new(unique::UniqueLowestBid::new(sender.clone(), token.clone())), + Box::new(twothirds::TwoThirdsAverage::new( + sender.clone(), + token.clone(), + )), + Box::new(soviet::SovietMafia::new(sender.clone(), token.clone())), + ] +} + +#[cfg(test)] +mod tests { + use super::default_games; + use crate::game::{Command, GameEvent, PlayerCommand}; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + fn dispatch( + games: &mut [Box], + sender: &str, + content: &str, + ) -> Vec<(String, crate::game::Reply)> { + let player_command = PlayerCommand { + sender: sender.to_string(), + content: content.to_string(), + }; + let command = Command::PlayerCommand(&player_command); + games + .iter_mut() + .map(|game| { + let name = game.name().to_string(); + let reply = game.next(&command); + (name, reply) + }) + .collect() + } + + fn test_catalog() -> Vec> { + let (sender, _receiver) = mpsc::unbounded_channel::(); + let token = CancellationToken::new(); + default_games(&sender, &token) + } + + #[test] + fn rps_flow_via_catalog() { + let mut games = test_catalog(); + + let first = dispatch(&mut games, "alice@example.org", "rps"); + let active: Vec<_> = first + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only RPS should respond to an rps join command" + ); + let (_, join_reply) = active[0]; + assert!(join_reply + .0 + .iter() + .any(|line| line.contains("You've asked to join a game of Rock, Paper, Scissors"))); + + let second = dispatch(&mut games, "bob@example.org", "rps"); + let active: Vec<_> = second + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only RPS should respond when the second player joins" + ); + let (_, start_reply) = active[0]; + assert!(start_reply + .0 + .iter() + .any(|line| line.contains("Got a partner!"))); + } + + #[test] + fn dice_validation_via_catalog() { + let mut games = test_catalog(); + let responses = dispatch(&mut games, "alice@example.org", "dice 0"); + let dice_reply = responses + .iter() + .find(|(name, _)| name == "dice") + .expect("Dice game should be present"); + assert!(dice_reply + .1 + .0 + .iter() + .any(|line| line.contains("number of dice to throw must be at least 1"))); + assert!( + responses + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .all(|(name, _)| name == "dice"), + "Only Dice should answer the invalid dice command" + ); + } + + #[test] + fn spoof_join_via_catalog() { + let mut games = test_catalog(); + let responses = dispatch(&mut games, "carol@example.org", "spoof"); + let active: Vec<_> = responses + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only Spoof should react to the spoof lobby command" + ); + assert_eq!(active[0].0, "spoof"); + assert!(active[0] + .1 + .0 + .iter() + .any(|line| line.contains("waiting for a game of spoof"))); + } + + #[test] + fn twothirds_join_via_catalog() { + let mut games = test_catalog(); + let responses = dispatch(&mut games, "dave@example.org", "twothirds"); + let active: Vec<_> = responses + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only Two Thirds should react to a twothirds join command" + ); + assert_eq!(active[0].0, "twothirds"); + assert!(active[0] + .1 + .0 + .iter() + .any(|line| line.contains("waiting for a two-thirds game"))); + } + + #[test] + fn unique_join_via_catalog() { + let mut games = test_catalog(); + let responses = dispatch(&mut games, "erin@example.org", "ulb"); + let active: Vec<_> = responses + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only Unique Lowest Bid should react to ulb" + ); + assert_eq!(active[0].0, "unique"); + assert!(active[0] + .1 + .0 + .iter() + .any(|line| line.contains("waiting for a unique lowest bid"))); + } + + #[test] + fn soviet_join_via_catalog() { + let mut games = test_catalog(); + let responses = dispatch(&mut games, "frank@example.org", "soviet"); + let active: Vec<_> = responses + .iter() + .filter(|(_, reply)| !reply.0.is_empty()) + .collect(); + assert_eq!( + active.len(), + 1, + "Only Soviet should react to the soviet lobby command" + ); + assert_eq!(active[0].0, "soviet"); + let body = &active[0].1 .0; + assert!(body + .iter() + .any(|line| line.contains("You enlist to defend the revolution"))); + assert!(body.iter().any(|line| line.contains("comrades waiting"))); + } +} Index: src/dice.rs ================================================================== --- src/dice.rs +++ src/dice.rs @@ -7,11 +7,11 @@ impl Game for Dice { fn new( _: tokio::sync::mpsc::UnboundedSender, _: tokio_util::sync::CancellationToken, ) -> Self { - Dice + 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+))?)?$") @@ -77,6 +77,10 @@ _ => {} } } r } + + fn name(&self) -> &'static str { + "dice" + } } Index: src/game.rs ================================================================== --- src/game.rs +++ src/game.rs @@ -1,21 +1,32 @@ use mastodon_async::Visibility; -/// This module contains the basic data structure of a game. -/// It's a trait that you can implement and create new games from. -/// Games are represented as state machines. - -/// The game trait. +use toml::Value; +/// Core abstraction shared by every game implementation. +/// +/// Games behave as state machines that consume commands and emit replies. The +/// trait keeps construction, stateless dispatch, and optional persistence in +/// one place so new games only need to worry about their own rules. pub trait Game: Send { /// A static method to create a new game. 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. + /// Advance the state machine by feeding it a command and capturing the reply. fn next(&mut self, m: &Command) -> Reply; + /// Name of the game, used for persistence. + fn name(&self) -> &'static str; + /// Serialize state to be persisted. Return None if the game is stateless. + fn save_state(&self) -> Option { + None + } + /// Restore state from persisted data. + fn load_state(&mut self, _value: &Value) -> Result<(), String> { + Ok(()) + } } /// A game response. For now just a vector of strings that must be tooted. /// In order to allow private replies, it also contains a visibility. #[derive(Debug)] @@ -23,10 +34,11 @@ impl Reply { pub fn new() -> Self { // By default, replies are sent as unlisted. Reply(Vec::new(), Visibility::Unlisted) } + /// Append a new message to the reply payload. pub fn push(&mut self, s: String) { self.0.push(s); } /// Set visibility to direct messages. pub fn quiet(&mut self) { @@ -39,10 +51,11 @@ pub sender: String, pub content: String, } /// An internal command: contains the match it is for, and a string. +#[derive(Clone)] pub struct InternalCommand { pub name: String, pub match_index: usize, pub command: String, } @@ -57,10 +70,12 @@ /// An event. pub enum GameEvent { /// A response to send. Reply(Reply), - /// Or an Internal Command.. + /// An internal command queued by a game itself to continue processing later. Step(InternalCommand), /// Or a fedi notification. Notification(crate::Notification), + /// Request to persist the game state. + Persist, } ADDED src/lib.rs Index: src/lib.rs ================================================================== --- /dev/null +++ src/lib.rs @@ -0,0 +1,13 @@ +pub use mastodon_async::prelude::Notification; +pub use once_cell::sync::Lazy; +pub use regex::Regex; +pub use tokio_util::sync::CancellationToken; + +pub mod catalog; +pub mod dice; +pub mod game; +pub mod rps; +pub mod soviet; +pub mod spoof; +pub mod twothirds; +pub mod unique; Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -1,194 +1,180 @@ -use futures_util::TryStreamExt; -use game::{Command, Game, GameEvent, PlayerCommand}; +use futures_util::{pin_mut, StreamExt, TryStreamExt}; +use mastodon_async::entities::notification::Notification; use mastodon_async::{ entities::notification::NotificationType, - helpers::{cli, toml}, + helpers::{cli, toml as mastodon_toml}, prelude::*, Language, Result, StatusBuilder, }; -use once_cell::sync::Lazy; -use regex::Regex; use reqwest::ClientBuilder; -use std::cmp::min; +use serde::{Deserialize, Serialize}; +use std::{cmp::min, collections::HashMap, fs, path::Path}; use tokio::{ - select, sync::mpsc, time::{sleep, Duration}, }; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; use tokio_util::sync::CancellationToken; +use url::{ParseError, Url}; -mod dice; -mod game; -mod rps; -//mod spoof; +use pravda::catalog; +use pravda::game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}; +use pravda::{Lazy, Regex}; +use toml::{self, Value}; /// Admin account. It can send administrative commands. const ADMIN: &str = "modulux@node.isonomia.net"; /// Quit command. const QUIT: &str = "!QUIT!"; /// Instance to connect to. const INSTANCE: &str = "https://botsin.space"; + +const STATE_FILE: &str = "game_state.toml"; // Maximum backing off level in powers of 2 of seconds (default: 8 = 256 sec = about 4 min). const MAX_BL: u8 = 8; /// Clean a toot. -async fn clean(s: &str) -> String { +fn clean(s: &str) -> String { static CLEAN: Lazy = Lazy::new(|| Regex::new(r"").expect("Problem compiling cleanup regexp.")); nanohtml2text::html2text(&CLEAN.replace_all(s, "")) .trim() .to_string() .to_lowercase() } /// Print out the text of a toot as plain text. -async fn print_status(s: &Status) { - println!( - "Received from {}: {}", - s.account.acct, - clean(&s.content).await - ); +fn print_status(sender: &str, content: &str) { + println!("Received from {}: {}", sender, content); } #[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(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(spoof)); - g.push(Box::new(dice)); + let mut game_manager = GameManager::new(&cs, &token); + if let Some(state) = load_persisted_state() { + game_manager.load_states(&state.games); + } + + { + let sender = cs.clone(); + let cancel = token.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = sleep(Duration::from_secs(60)) => { + if sender.send(GameEvent::Persist).is_err() { + break; + } + } + } + } + }); + } let web_client = ClientBuilder::new() .user_agent("Werewolf 0.1") .build() .expect("Problem building Reqwest client."); - let mastodon = if let Ok(data) = toml::from_file("mastodon-data.toml") { + let mastodon = if let Ok(data) = mastodon_toml::from_file("mastodon-data.toml") { 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(); + let posting_client = mastodon.clone(); + let bot_account = mastodon + .verify_credentials() + .await + .map(|account| account.acct) + .unwrap_or_default(); + // Event loop, runs on another thread. tokio::spawn(async move { println!("Initiating main event loop."); while let Some(event) = cr.recv().await { match event { GameEvent::Notification(n) if n.notification_type == NotificationType::Mention => { let sender = n.account.acct; + if sender == bot_account { + continue; + } // Time to check if we got a quit message. - let s = n - .status - .expect("Event is a notification but does not have a status."); - let content = clean(&s.content).await; - if sender == ADMIN && s.content.contains(QUIT) { + let Some(status) = n.status else { + continue; + }; + let content = clean(&status.content); + if sender == ADMIN && status.content.contains(QUIT) { println!("Admin sent us the shut down command."); token.cancel(); break; } - print_status(&s).await; - // Treat the toot like a command. + print_status(&sender, &content); + // Treat the toot like a command. + if content.is_empty() { + continue; + } 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(&Command::PlayerCommand(&comm)); - // And for each reply. - // Take the visibility. - let v = r.1; - for j in r.0 { - // We send it. - println!("Sent: {}", j); - let status = StatusBuilder::new() - .status(j.to_string()) - .language(Language::Eng) - .visibility(v) - .build(); - let _ = - post_status(&m, status.expect("Problem posting a status.")).await; - } - } + game_manager.dispatch_player(&posting_client, &comm).await; + } + GameEvent::Reply(reply) => { + process_reply(&posting_client, reply).await; + } + GameEvent::Step(internal) => { + game_manager + .dispatch_internal(&posting_client, internal) + .await; + } + GameEvent::Persist => { + game_manager.persist().await; } _ => (), } } }); - let mut l = 0; - let mut bl = 0; - 'stream: loop { + let mut attempt: u32 = 0; + let mut backoff: u8 = 0; + loop { println!( - "Initiating streaming API... Loop: {}. Backing off level: {}.", - l, bl + "Initiating streaming API... Attempt: {}. Backing off level: {}.", + attempt, backoff ); - let stream = match mastodon.stream_notifications().await { - Ok(stream) => { - if bl > 0 { - bl -= 1; - } - stream - } - _ => { - 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); - } - continue; - } - }; - - select! { - _ = token2.cancelled() => - { - println!("Shutting down..."); - break 'stream; - } - - _ = - stream.try_for_each(|(event, _)| async { - 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); - } - Some(()) = async { - loop { - sleep(Duration::from_secs(30)).await; - if bl > 0 { - bl -= 1; - println!("Backing off level now {}.", bl); - } - // Return value for the type checker. - if false { return None } - } - } - => - {} - }; - println!("Streaming terminated. Loop: {}. Backing off: {}", l, bl); - l += 1; + + match stream_notifications_loop(&mastodon, &cs, &token2).await { + StreamOutcome::Cancelled => { + println!("Shutting down..."); + break; + } + StreamOutcome::Finished => { + if backoff > 0 { + backoff -= 1; + } + } + StreamOutcome::Error(err) => { + eprintln!("Streaming error: {}", err); + let delay = Duration::from_secs(2u64.pow(u32::from(min(MAX_BL, backoff)))); + sleep(delay).await; + if backoff < MAX_BL { + backoff += 1; + println!("Backing off level now {}.", backoff); + } + } + } + + attempt = attempt.saturating_add(1); } Ok(()) } @@ -203,15 +189,312 @@ .build() .await?; let mastodon = cli::authenticate(registration).await?; // Save app data for using on the next run. - toml::to_file(&mastodon.data, "mastodon-data.toml")?; + mastodon_toml::to_file(&mastodon.data, "mastodon-data.toml")?; Ok(mastodon) } async fn post_status(m: &Mastodon, s: NewStatus) -> Result<()> { m.new_status(s).await?; - // println!("Sent: {}", clean(&st.content.to_string()).await); Ok(()) } + +async fn process_reply(mastodon: &Mastodon, reply: Reply) { + let visibility = reply.1; + for message in reply.0 { + if let Some(sanitized) = sanitize_outgoing(&message) { + println!("Sent: {}", sanitized); + match StatusBuilder::new() + .status(sanitized) + .language(Language::Eng) + .visibility(visibility) + .build() + { + Ok(status) => { + if let Err(err) = post_status(mastodon, status).await { + eprintln!("Error posting status: {}", err); + } + } + Err(err) => eprintln!("Unable to build status: {}", err), + } + } + } +} + +/// Normalise and truncate outgoing messages to stay within Mastodon limits. +fn sanitize_outgoing(message: &str) -> Option { + const MAX_STATUS_BYTES: usize = 480; // leave headroom for mentions and signatures + let trimmed = message.trim(); + if trimmed.is_empty() { + return None; + } + + let mut sanitized = String::with_capacity(trimmed.len().min(MAX_STATUS_BYTES)); + let mut last_was_space = false; + for ch in trimmed.chars() { + if ch.is_control() && ch != '\n' && ch != '\t' { + continue; + } + let normalized = match ch { + '\r' => '\n', + _ => ch, + }; + if normalized.is_whitespace() && normalized != '\n' { + if last_was_space { + continue; + } + sanitized.push(' '); + last_was_space = true; + } else { + sanitized.push(normalized); + last_was_space = false; + } + if sanitized.len() >= MAX_STATUS_BYTES { + break; + } + } + + let collapsed = sanitized.trim().to_string(); + if collapsed.is_empty() { + None + } else { + Some(collapsed) + } +} + +/// Coordinates the lifecycle of every enabled game and persistence hooks. +struct GameManager { + games: Vec>, +} + +impl GameManager { + fn new(channel: &mpsc::UnboundedSender, token: &CancellationToken) -> Self { + let games = catalog::default_games(channel, token); + Self { games } + } + + fn load_states(&mut self, states: &HashMap) { + for game in self.games.iter_mut() { + if let Some(value) = states.get(game.name()) { + if let Err(err) = game.load_state(value) { + eprintln!("Failed to load state for game '{}': {}", game.name(), err); + } + } + } + } + + async fn dispatch_player(&mut self, mastodon: &Mastodon, command: &PlayerCommand) { + for game in self.games.iter_mut() { + let reply = game.next(&Command::PlayerCommand(command)); + process_reply(mastodon, reply).await; + } + } + + async fn dispatch_internal(&mut self, mastodon: &Mastodon, internal: InternalCommand) { + for game in self.games.iter_mut() { + let reply = game.next(&Command::InternalCommand(internal.clone())); + process_reply(mastodon, reply).await; + } + } + + async fn persist(&mut self) { + let mut games_state = HashMap::new(); + for game in self.games.iter() { + if let Some(value) = game.save_state() { + games_state.insert(game.name().to_string(), value); + } + } + let state = PersistedState { games: games_state }; + match toml::to_string(&state) { + Ok(serialised) => { + if let Err(err) = tokio::fs::write(STATE_FILE, serialised).await { + eprintln!("Failed to persist game state: {}", err); + } + } + Err(err) => eprintln!("Failed to serialise game state: {}", err), + } + } +} + +#[derive(Serialize, Deserialize, Default)] +struct PersistedState { + games: HashMap, +} + +/// Load persisted state from disk if present, logging any parse errors. +fn load_persisted_state() -> Option { + if !Path::new(STATE_FILE).exists() { + return None; + } + match fs::read_to_string(STATE_FILE) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(state) => Some(state), + Err(err) => { + eprintln!("Failed to parse persisted game state: {}", err); + None + } + }, + Err(err) => { + eprintln!("Failed to read persisted game state: {}", err); + None + } + } +} + +enum StreamOutcome { + Cancelled, + Finished, + Error(StreamError), +} + +#[derive(Debug)] +enum StreamError { + Api(mastodon_async::Error), + Websocket(tokio_tungstenite::tungstenite::Error), + Url(ParseError), + Json(serde_json::Error), + UnsupportedScheme, +} + +impl std::fmt::Display for StreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StreamError::Api(err) => write!(f, "API stream error: {}", err), + StreamError::Websocket(err) => write!(f, "WebSocket error: {}", err), + StreamError::Url(err) => write!(f, "Invalid streaming URL: {}", err), + StreamError::Json(err) => write!(f, "Invalid streaming payload: {}", err), + StreamError::UnsupportedScheme => { + write!(f, "Unsupported instance scheme for WebSocket") + } + } + } +} + +impl std::error::Error for StreamError {} + +async fn stream_notifications_loop( + mastodon: &Mastodon, + sender: &mpsc::UnboundedSender, + cancel: &CancellationToken, +) -> StreamOutcome { + match stream_notifications_ws(mastodon, sender, cancel).await { + Ok(outcome) => outcome, + Err(err) => { + eprintln!("WebSocket unavailable, falling back to SSE: {}", err); + match stream_notifications_sse(mastodon, sender, cancel).await { + Ok(outcome) => outcome, + Err(err) => StreamOutcome::Error(err), + } + } + } +} + +async fn stream_notifications_sse( + mastodon: &Mastodon, + sender: &mpsc::UnboundedSender, + cancel: &CancellationToken, +) -> std::result::Result { + let stream = mastodon + .stream_notifications() + .await + .map_err(StreamError::Api)? + .into_stream(); + pin_mut!(stream); + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(StreamOutcome::Cancelled), + result = stream.next() => match result { + Some(Ok((Event::Notification(notification), _))) => { + if sender.send(GameEvent::Notification(notification)).is_err() { + return Ok(StreamOutcome::Cancelled); + } + } + Some(Ok(_)) => {} + Some(Err(err)) => return Err(StreamError::Api(err)), + None => return Ok(StreamOutcome::Finished), + } + } + } +} + +async fn stream_notifications_ws( + mastodon: &Mastodon, + sender: &mpsc::UnboundedSender, + cancel: &CancellationToken, +) -> std::result::Result { + let url = build_websocket_url(mastodon)?; + let (mut stream, _) = connect_async(url).await.map_err(StreamError::Websocket)?; + + loop { + tokio::select! { + _ = cancel.cancelled() => { + let _ = stream.close(None).await; + return Ok(StreamOutcome::Cancelled); + } + message = stream.next() => match message { + Some(Ok(Message::Text(text))) => { + if let Some(notification) = parse_ws_notification(&text)? { + if sender.send(GameEvent::Notification(notification)).is_err() { + return Ok(StreamOutcome::Cancelled); + } + } + } + Some(Ok(Message::Ping(payload))) => { + use futures_util::SinkExt; + stream + .send(Message::Pong(payload)) + .await + .map_err(StreamError::Websocket)?; + } + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) => return Ok(StreamOutcome::Finished), + Some(Ok(Message::Binary(_))) => {} + Some(Ok(Message::Frame(_))) => {} + Some(Err(err)) => return Err(StreamError::Websocket(err)), + None => return Ok(StreamOutcome::Finished), + } + } + } +} + +fn build_websocket_url(mastodon: &Mastodon) -> std::result::Result { + let mut url = Url::parse(mastodon.data.base.as_ref()).map_err(StreamError::Url)?; + match url.scheme() { + "https" => url + .set_scheme("wss") + .map_err(|_| StreamError::UnsupportedScheme)?, + "http" => url + .set_scheme("ws") + .map_err(|_| StreamError::UnsupportedScheme)?, + "wss" | "ws" => {} + _ => return Err(StreamError::UnsupportedScheme), + } + url.set_path("/api/v1/streaming/user/notification"); + { + let mut pairs = url.query_pairs_mut(); + pairs.clear(); + pairs.append_pair("access_token", mastodon.data.token.as_ref()); + } + Ok(url) +} + +#[derive(Debug, Deserialize)] +struct WebsocketEnvelope { + event: Option, + payload: Option, +} + +fn parse_ws_notification(text: &str) -> std::result::Result, StreamError> { + let envelope: WebsocketEnvelope = serde_json::from_str(text).map_err(StreamError::Json)?; + if envelope.event.as_deref() != Some("notification") { + return Ok(None); + } + let payload = match envelope.payload { + Some(payload) => payload, + None => return Ok(None), + }; + let notification: Notification = serde_json::from_str(&payload).map_err(StreamError::Json)?; + Ok(Some(notification)) +} Index: src/rps.rs ================================================================== --- src/rps.rs +++ src/rps.rs @@ -1,13 +1,16 @@ use crate::game::{Command, Game, GameEvent, Reply}; +use serde::{Deserialize, Serialize}; use std::{ cmp::Ordering, collections::{HashMap, HashSet}, }; +use toml::Value; /// Rock, Paper, Scissors. /// A match. Considering to move this to Game. +#[derive(Clone, Serialize, Deserialize)] struct Match { state: HashMap>, } impl Match { @@ -84,14 +87,16 @@ 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. - matches: Vec, + /// Map of ongoing matches keyed by stable identifier. + matches: HashMap, /// HashSet indicating for each player which match they are in. players: HashMap, + /// Next match identifier to allocate. + next_match_id: usize, } impl Game for Rps { /// Creation of a new and empty Rps game structure. fn new( @@ -99,199 +104,286 @@ _token: tokio_util::sync::CancellationToken, ) -> Self { Rps { lobby: HashSet::new(), capacity: 2, - matches: Vec::new(), + matches: HashMap::new(), players: HashMap::new(), + next_match_id: 0, } } /// 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 mut reply = Reply::new(); let m = match m { Command::PlayerCommand(c) => c, Command::InternalCommand(_) => { - return r; + return reply; } }; - // 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); - assert!(!(waiting & playing)); - // It is possible for a command to be: rps, a move in the game, or cancelrps. + assert!(!(waiting && playing)); + + if m.content == "help" { + reply.quiet(); + if playing { + if let Some(&match_id) = self.players.get(&m.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + let opponent = current_match.opponent(&m.sender); + reply.push(format!( + "@{} You're in a Rock, Paper, Scissors match with {}. Send 'rock', 'paper', or 'scissors' to play, or 'cancelrps' to forfeit.", + m.sender, opponent + )); + return reply; + } + } + reply.push(format!( + "@{} You're in a Rock, Paper, Scissors match. Send 'rock', 'paper', or 'scissors' to play, or 'cancelrps' to forfeit.", + m.sender + )); + } else if waiting { + reply.push(format!( + "@{} Waiting for an opponent. I'll start the match as soon as someone else sends 'rps'.", + m.sender + )); + } else { + reply.push( + "Send 'rps' to join the lobby. Once matched, play with 'rock', 'paper', or 'scissors'. Use 'cancelrps' to withdraw.". + to_string(), + ); + } + return reply; + } + let joining = m.content == "rps"; let quitting = m.content == "cancelrps"; let choice = match m.content.as_str() { "rock" => Some(Play::Rock), "paper" => Some(Play::Paper), "scissors" => Some(Play::Scissors), _ => None, }; - // 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. - 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. - (true, false, None, false, false) => { - // This has two cases. - // If there's nobody waiting. - if self.capacity > 1 { - // Put sender in the lobby. Reduce capacity. - r.push(format!("@{} 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.", m.sender)); - self.lobby.insert(m.sender.clone()); - self.capacity -= 1; - } - // If Someone's waiting, start the game. - else { - // Put player in the lobby. This avoids us making players mut. - self.lobby.insert(m.sender.clone()); - // Get the playes from the lobby. - let players = self.lobby.clone(); - // Create this match. - let this_match = Match::new(&players); - // Empty the lobby. - self.lobby = HashSet::new(); - // Reset capacity. - self.capacity = 2; - // Add the match to the matches vector. - self.matches.push(this_match); - // Get the index. - let n = self.matches.len() - 1; - // Place the match index for each player. - for i in players { - self.players.insert(i, n); - } - // Prepare the replies. - // Make this reply quiet, so that the move is responded in DM. - r.quiet(); - for i in self.matches[n].state.keys() { - r.push(format!( - "@{} Got a partner! Your opponent is {} - -Tell me your choice: *rock*, *paper*, or *scissors*?", - i, - self.matches[n].opponent(i) - )); - } - } - } - - // We're trying to join, but already in the lobby. - (true, false, None, false, true) => { - r.push(format!( + + match (joining, quitting, choice, playing, waiting) { + (true, false, None, false, false) => { + if self.capacity > 1 { + reply.push(format!("@{} 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.", m.sender)); + self.lobby.insert(m.sender.clone()); + self.capacity -= 1; + } else { + self.lobby.insert(m.sender.clone()); + let players = self.lobby.clone(); + let participants: Vec = players.iter().cloned().collect(); + self.lobby.clear(); + self.capacity = 2; + let match_id = self.next_match_id; + self.next_match_id = self.next_match_id.saturating_add(1); + let match_instance = Match::new(&players); + for participant in &participants { + self.players.insert(participant.clone(), match_id); + } + self.matches.insert(match_id, match_instance); + reply.quiet(); + if let Some(current_match) = self.matches.get(&match_id) { + for participant in current_match.state.keys() { + reply.push(format!( + "@{} Got a partner! Your opponent is {} + +Tell me your choice: *rock*, *paper*, or *scissors*?", + participant, + current_match.opponent(participant) + )); + } + } + } + } + (true, false, None, false, true) => { + reply.push(format!( "@{} You're already waiting for a game of Rock, Paper, Scissors. Be patient.", m.sender )); } - // We're trying to join, but already playing. (true, false, None, true, false) => { - r.push(format!( - "@{} You're already playing a game against {} + if let Some(&match_id) = self.players.get(&m.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + reply.push(format!( + "@{} You're already playing a game against {} You can cancel it with *cancelrps* if you're bored of waiting.", - m.sender, - self.matches[*self.players.get(&m.sender).expect("Trying to get the match index for a player who sent a join command and is playing a game, but is not in the set.")].opponent(&m.sender) + m.sender, + current_match.opponent(&m.sender) + )); + } else { + reply.push(format!( + "@{} You're already in a Rock, Paper, Scissors match. Send 'cancelrps' if you want out.", + m.sender + )); + } + } + } + (false, true, None, false, true) => { + self.lobby.remove(&m.sender); + self.capacity = 2; + reply.push(format!( + "@{} You're no longer waiting for a partner. You may play again by sending *rps* any time.", + m.sender )); } - - // Now we do the two quit cases: while waiting, and while playing. - // While waiting, it only affects the player. - (false, true, None, false, true) => { - // Remove player from lobby. - self.lobby.remove(&m.sender); - // Reset capacity. - self.capacity = 2; - // Send message. - r.push(format!( -"@{} You're no longer waiting for a partner. You may play again by sending *rps* any time.", m.sender)); - } - // While playing, it affects both players. (false, true, None, true, false) => { - // Get our match index. - let n = self.players.get(&m.sender).expect("Trying to get the match of a player who sent a cancel command while playing a game, but is not in the set."); - // Get our opponent. - let o = self.matches[*n].opponent(&m.sender); - // Remove the match. - self.matches.remove(*n); - // Remove both players from the player list. - self.players.remove(&m.sender); - self.players.remove(&o); - // The simplest way to do a cancellation is to tell both players. - // TODO: The non-cancelling player can go to the lobby in a future version. - r.push(format!( - "@{} has cancelled the game with @{} + if let Some(&match_id) = self.players.get(&m.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + let opponent = current_match.opponent(&m.sender); + self.matches.remove(&match_id); + self.players.remove(&m.sender); + self.players.remove(&opponent); + reply.push(format!( + "@{} has cancelled the game with @{} You're both welcome to play again any time. Use *rps* to start a new match.", - m.sender, o - )); + m.sender, opponent + )); + } else { + self.players.remove(&m.sender); + reply.push("Your match has already ended.".to_string()); + } + } } - // Now we deal with making a move. - (false, false, Some(c), true, false) => { - // Our name for later insertion. + (false, false, Some(choice), true, false) => { let name = m.sender.clone(); - // Get our match index. - let n = self.players.get(&name).expect("Trying to get the match of a player who made a move while playing a game, but is not in the set."); - // Get our opponent. - let o = self.matches[*n].opponent(&name); - // If we already played: - if self.matches[*n].state.get(&name).expect("Trying to get the match of a player making a move while playing a game, but is not in the set.").is_some() { - // We can't play twice. - r.push(format!( - "@{} You already sent me your choice. You need to wait for {} + let Some(&match_id) = self.players.get(&name) else { + reply.push("Your match has already ended.".to_string()); + return reply; + }; + + let mut resolution: Option<(Vec, Reply)> = None; + + if let Some(current_match) = self.matches.get_mut(&match_id) { + if current_match + .state + .get(&name) + .expect("Trying to get the match of a player making a move while playing a game, but is not in the set.") + .is_some() + { + reply.push(format!( + "@{} You already sent me your choice. You need to wait for {} If you get bored, you can send me *cancelrps* to cancel the game.", - name, o - )); - } else { - // Put our choice in the match. - self.matches[*n].state.insert(name, Some(c)); - // Check if the match is done. - if self.matches[*n].is_ready() { - // We're ready. Solve the game. - r = self.matches[*n].solve(); - // Clean up. - self.matches.remove(*n); - self.players.remove(&m.sender); - self.players.remove(&o); - } else { - // Game isn't over, just send the message. - r.push(format!( + name, + current_match.opponent(&name) + )); + return reply; + } + + current_match.state.insert(name.clone(), Some(choice)); + if current_match.is_ready() { + let players: Vec = current_match.state.keys().cloned().collect(); + let solve_reply = current_match.solve(); + resolution = Some((players, solve_reply)); + } else { + reply.push(format!( "@{} Got your move. Let's see what your opponent does.", m.sender )); } - } - } - // And moves out of time. - // When we're not in a game or waiting to play. - (false, false, Some(_), false, false) => { - r.push(format!("@{} You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", m.sender)); - } - // When we're still waiting for a partner. - (false, false, Some(_), false, true) => { - r.push(format!("@{} You're still waiting for a partner. Be patient. If you want, you can sende me *cancelrps* to cancel the game.", m.sender)); - } - - _ => {} // __ - } - - r - } + } else { + self.players.remove(&name); + reply.push("Your match has already ended.".to_string()); + return reply; + } + + if let Some((participants, resolve_reply)) = resolution { + self.matches.remove(&match_id); + for participant in participants { + self.players.remove(&participant); + } + return resolve_reply; + } + } + (false, false, Some(_), false, false) => { + reply.push(format!( + "@{} You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", + m.sender + )); + } + (false, false, Some(_), false, true) => { + reply.push(format!( + "@{} You're still waiting for a partner. Be patient. If you want, you can sende me *cancelrps* to cancel the game.", + m.sender + )); + } + _ => {} + } + + reply + } + + fn name(&self) -> &'static str { + "rps" + } + + fn save_state(&self) -> Option { + let lobby: Vec = self.lobby.iter().cloned().collect(); + let matches: Vec = self + .matches + .iter() + .map(|(id, m)| RpsMatchEntry { + id: *id, + state: m.clone(), + }) + .collect(); + let state = RpsState { + lobby, + capacity: self.capacity, + matches, + next_match_id: self.next_match_id, + }; + Value::try_from(state).ok() + } + + fn load_state(&mut self, value: &Value) -> Result<(), String> { + let state: RpsState = value.clone().try_into().map_err(|e| e.to_string())?; + self.lobby = state.lobby.into_iter().collect(); + self.capacity = state.capacity; + self.matches = state + .matches + .into_iter() + .map(|entry| (entry.id, entry.state)) + .collect(); + self.players.clear(); + for (match_id, m) in &self.matches { + for player in m.state.keys() { + self.players.insert(player.clone(), *match_id); + } + } + self.next_match_id = state.next_match_id; + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct RpsMatchEntry { + id: usize, + state: Match, +} + +#[derive(Serialize, Deserialize)] +struct RpsState { + lobby: Vec, + capacity: u8, + matches: Vec, + next_match_id: usize, } /// Valid plays. -#[derive(PartialEq, Debug)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] enum Play { Rock, Paper, Scissors, } @@ -485,10 +577,27 @@ 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_help_while_playing() { + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let p1 = "modulux@node.isonomia.net".to_string(); + let p2 = "modulux2@node.isonomia.net".to_string(); + command(&mut g, (p1.clone(), "rps".to_string())); + command(&mut g, (p2.clone(), "rps".to_string())); + let help_reply = command(&mut g, (p1.clone(), "help".to_string())); + assert!(help_reply + .0 + .iter() + .any(|msg| msg.contains("rock', 'paper', or 'scissors"))); + } #[test] fn test_join_full_then_cancel() { let r; let mut g = Rps::new( @@ -555,6 +664,29 @@ You're both welcome to play again any time. Use *rps* to start a new match.", "Incorrect response. {}.", r.0[0] ); } + + #[test] + fn test_parallel_matches() { + let mut g = Rps::new( + tokio::sync::mpsc::unbounded_channel().0, + crate::CancellationToken::new(), + ); + let p1 = "modulux@node.isonomia.net".to_string(); + let p2 = "modulux2@node.isonomia.net".to_string(); + let p3 = "modulux3@node.isonomia.net".to_string(); + let p4 = "modulux4@node.isonomia.net".to_string(); + + command(&mut g, (p1.clone(), "rps".to_string())); + command(&mut g, (p2.clone(), "rps".to_string())); + command(&mut g, (p3.clone(), "rps".to_string())); + command(&mut g, (p4.clone(), "rps".to_string())); + + let match_one = g.players.get(&p1).cloned(); + let match_two = g.players.get(&p3).cloned(); + assert!(match_one.is_some() && match_two.is_some()); + assert_ne!(match_one, match_two); + assert_eq!(g.matches.len(), 2); + } } ADDED src/soviet.rs Index: src/soviet.rs ================================================================== --- /dev/null +++ src/soviet.rs @@ -0,0 +1,1793 @@ +use crate::game::{Command, Game, GameEvent, PlayerCommand, Reply}; +use crate::CancellationToken; +use rand::seq::SliceRandom; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tokio::{ + sync::mpsc, + task::JoinHandle, + time::{sleep, Duration}, +}; +use toml::Value; + +const MIN_PLAYERS: usize = 6; +const REMINDER_DELAY: Duration = Duration::from_secs(300); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum Stage { + Lobby, + Day(u32), + Night(u32), + Finished, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum Alignment { + Workers, + Saboteurs, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +enum Role { + Worker, + Saboteur, + PoliticalCommissar, + Chekist { ammo: u8, loaded: bool }, + Militsioner, + RootlessCosmopolitan, + Spy, + TrotskyiteMastermind, + Stalin { revealed: bool }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct PlayerState { + role: Role, + alignment: Alignment, + alive: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +enum ChekistOrder { + Shoot(String), + Unload, + Reload, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct SpyNight { + probe: Option, + frame: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +enum StalinOrder { + Verify, + Rehabilitate(String), +} + +struct SovietMatch { + lobby: HashSet, + players: HashMap, + stage: Stage, + day: u32, + night: u32, + votes: HashMap>, + saboteur_votes: HashMap>, + commissar_target: Option, + chekist_action: Option, + militsioner_target: Option, + spy_actions: HashMap, + mastermind_target: Option, + stalin_action: Option, + framed_players: HashSet, + channel: mpsc::UnboundedSender, + _token: CancellationToken, + reminder_task: Option>, +} + +impl SovietMatch { + fn new(channel: mpsc::UnboundedSender, token: CancellationToken) -> Self { + SovietMatch { + lobby: HashSet::new(), + players: HashMap::new(), + stage: Stage::Lobby, + day: 0, + night: 0, + votes: HashMap::new(), + saboteur_votes: HashMap::new(), + commissar_target: None, + chekist_action: None, + militsioner_target: None, + spy_actions: HashMap::new(), + mastermind_target: None, + stalin_action: None, + framed_players: HashSet::new(), + channel, + _token: token, + reminder_task: None, + } + } + + fn stage(&self) -> Stage { + self.stage + } + + fn participant_names(&self) -> Vec { + let mut participants: HashSet = self.players.keys().cloned().collect(); + participants.extend(self.lobby.iter().cloned()); + participants.into_iter().collect() + } + + fn to_state(&self) -> SovietMatchState { + SovietMatchState { + lobby: self.lobby.iter().cloned().collect(), + players: self.players.clone(), + stage: self.stage, + day: self.day, + night: self.night, + votes: self.votes.clone(), + saboteur_votes: self.saboteur_votes.clone(), + commissar_target: self.commissar_target.clone(), + chekist_action: self.chekist_action.clone(), + militsioner_target: self.militsioner_target.clone(), + spy_actions: self.spy_actions.clone(), + mastermind_target: self.mastermind_target.clone(), + stalin_action: self.stalin_action.clone(), + framed_players: self.framed_players.iter().cloned().collect(), + } + } + + fn apply_state(&mut self, state: SovietMatchState) { + self.cancel_reminder(); + self.lobby = state.lobby.into_iter().collect(); + self.players = state.players; + self.stage = state.stage; + self.day = state.day; + self.night = state.night; + self.votes = state.votes; + self.saboteur_votes = state.saboteur_votes; + self.commissar_target = state.commissar_target; + self.chekist_action = state.chekist_action; + self.militsioner_target = state.militsioner_target; + self.spy_actions = state.spy_actions; + self.mastermind_target = state.mastermind_target; + self.stalin_action = state.stalin_action; + self.framed_players = state.framed_players.into_iter().collect(); + self.schedule_reminder_if_needed(); + } + + fn is_lobby_command(words: &[&str]) -> bool { + match words.first().copied() { + Some("soviet") | Some("status") | Some("help") => true, + Some("leave") => matches!(words.get(1), Some(&"soviet")), + _ => false, + } + } + + fn is_day_command(words: &[&str]) -> bool { + matches!( + words.first().copied(), + Some("vote") | Some("retract") | Some("status") | Some("role") | Some("help") + ) + } + + fn is_night_command(words: &[&str]) -> bool { + matches!( + words.first().copied(), + Some("liquidate") + | Some("investigate") + | Some("shoot") + | Some("unload") + | Some("reload") + | Some("guard") + | Some("spy") + | Some("frame") + | Some("subvert") + | Some("verify") + | Some("rehabilitate") + | Some("status") + | Some("role") + | Some("help") + ) + } + + fn command_is_relevant(&self, words: &[&str]) -> bool { + if words.is_empty() { + return true; + } + match self.stage { + Stage::Lobby | Stage::Finished => Self::is_lobby_command(words), + Stage::Day(_) => Self::is_day_command(words), + Stage::Night(_) => Self::is_night_command(words), + } + } + + fn reset_night_state(&mut self) { + self.saboteur_votes.clear(); + self.commissar_target = None; + self.chekist_action = None; + self.militsioner_target = None; + self.spy_actions.clear(); + self.mastermind_target = None; + self.stalin_action = None; + self.cancel_reminder(); + } + + fn alive_players(&self) -> Vec { + self.players + .iter() + .filter_map(|(p, state)| state.alive.then(|| p.clone())) + .collect() + } + + fn worker_count(&self) -> usize { + self.players + .values() + .filter(|p| p.alive && p.alignment == Alignment::Workers) + .count() + } + + fn saboteur_count(&self) -> usize { + self.players + .values() + .filter(|p| p.alive && p.alignment == Alignment::Saboteurs) + .count() + } + + fn is_alive(&self, player: &str) -> bool { + self.players.get(player).map(|p| p.alive).unwrap_or(false) + } + + fn send_direct(&self, message: String) { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(message); + let _ = self.channel.send(GameEvent::Reply(reply)); + } + + fn emit_public(&self, message: String) { + let mut reply = Reply::new(); + reply.push(message); + let _ = self.channel.send(GameEvent::Reply(reply)); + } + + fn join_lobby(&mut self, player: &str) -> Reply { + if self.stage != Stage::Lobby { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} A Soviet game is already in progress; you cannot join now.", + player + )); + return reply; + } + if self.lobby.contains(player) { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} You are already waiting for the next Soviet game.", + player + )); + return reply; + } + self.lobby.insert(player.to_string()); + let mut reply = Reply::new(); + reply.quiet(); + let waiting = self.lobby.len(); + let needed = MIN_PLAYERS.saturating_sub(waiting); + reply.push(format!( + "@{} You enlist to defend the revolution. {} comrades waiting.", + player, waiting + )); + if needed == 0 { + reply.push( + "We have enough comrades to begin. Any player may send 'soviet start' to launch the game.". + to_string(), + ); + } else { + reply.push(format!( + "We need {} more comrades ({} total) before someone sends 'soviet start'.", + needed, MIN_PLAYERS + )); + } + reply + } + + fn leave_lobby(&mut self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if self.stage != Stage::Lobby { + reply.push(format!( + "@{} The purges have already begun; you cannot withdraw now.", + player + )); + return reply; + } + if self.lobby.remove(player) { + reply.push(format!( + "@{} You step away from the soviet assembly. {} comrades remain waiting.", + player, + self.lobby.len() + )); + } else { + reply.push(format!( + "@{} You were not in the queue for the next Soviet game.", + player + )); + } + reply + } + + fn lobby_status(&self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + match self.stage { + Stage::Lobby => { + let mut list: Vec<_> = self.lobby.iter().cloned().collect(); + list.sort(); + reply.push(format!( + "@{} Waiting comrades: {}.", + player, + if list.is_empty() { + "none".to_string() + } else { + list.join(", ") + } + )); + let waiting = self.lobby.len(); + let needed = MIN_PLAYERS.saturating_sub(waiting); + if waiting == 0 { + reply.push( + "Recruit comrades by sending 'soviet' once you have at least six ready." + .to_string(), + ); + } else if needed == 0 { + reply.push( + "Enough players are assembled. Any player may send 'soviet start' to begin.".to_string(), + ); + } else { + reply.push(format!( + "{} waiting of {} required. Need {} more before using 'soviet start'.", + waiting, MIN_PLAYERS, needed + )); + } + } + Stage::Day(day) => { + let alive = self.alive_players(); + reply.push(format!( + "@{} Day {} in progress. Survivors: {}.", + player, + day, + alive.join(", ") + )); + } + Stage::Night(night) => { + let alive = self.alive_players(); + reply.push(format!( + "@{} Night {} in progress. Survivors: {}.", + player, + night, + alive.join(", ") + )); + } + Stage::Finished => { + reply.push(format!( + "@{} The most recent Soviet game has concluded.", + player + )); + } + } + reply + } + + fn start_game(&mut self, player: &str) -> Reply { + if self.stage != Stage::Lobby { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} A Soviet game is already running.", player)); + return reply; + } + if self.lobby.len() < MIN_PLAYERS { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Not enough comrades for the purge. We need at least {}.", + player, MIN_PLAYERS + )); + return reply; + } + + self.assign_roles(); + self.day = 1; + self.night = 0; + self.stage = Stage::Day(self.day); + self.votes.clear(); + self.reset_night_state(); + + let alive = self.alive_players(); + let public_message = format!( + "The Soviet convenes. Day {} dawns over {} loyal citizens.", + self.day, + alive.len() + ); + self.send_role_messages(); + self.lobby.clear(); + let mut reply = Reply::new(); + reply.push(public_message); + reply + } + + fn assign_roles(&mut self) { + let mut rng = rand::thread_rng(); + let mut players: Vec = self.lobby.iter().cloned().collect(); + players.shuffle(&mut rng); + let n = players.len(); + + let mut roles: Vec = Vec::new(); + let saboteur_slots = (n / 3).max(1); + for _ in 0..saboteur_slots { + roles.push(Role::Saboteur); + } + if n >= 7 { + roles.push(Role::TrotskyiteMastermind); + } + if n >= 6 { + roles.push(Role::Spy); + roles.push(Role::Chekist { + ammo: 8, + loaded: true, + }); + roles.push(Role::Militsioner); + } + if n >= 5 { + roles.push(Role::PoliticalCommissar); + roles.push(Role::RootlessCosmopolitan); + } + if n >= 8 { + roles.push(Role::Stalin { revealed: false }); + } + + while roles.len() < n { + roles.push(Role::Worker); + } + roles.truncate(n); + roles.shuffle(&mut rng); + + self.players.clear(); + for (player, role) in players.into_iter().zip(roles.into_iter()) { + let alignment = match role { + Role::Saboteur | Role::TrotskyiteMastermind => Alignment::Saboteurs, + _ => Alignment::Workers, + }; + self.players.insert( + player, + PlayerState { + role, + alignment, + alive: true, + }, + ); + } + } + + fn send_role_messages(&self) { + let mut saboteurs = Vec::new(); + for (player, state) in &self.players { + if state.alive && state.alignment == Alignment::Saboteurs { + saboteurs.push(player.clone()); + } + } + for (player, state) in &self.players { + let description = match &state.role { + Role::Worker => "Worker: loyal Soviet citizen. Root out the saboteurs!".to_string(), + Role::Saboteur => "Trotskyite saboteur: coordinate with your fellow traitors to liquidate workers at night.".to_string(), + Role::PoliticalCommissar => "Political Commissar: each night use 'investigate ' to test loyalty. Be wary of false positives.".to_string(), + Role::Chekist { .. } => "Chekist: 'shoot ' at night to eliminate enemies. Beware misfires and limited ammunition. 'unload' or 'reload' to manage your TT-33.".to_string(), + Role::Militsioner => "Militsioner: 'guard ' each night to protect them, often at the cost of your own life.".to_string(), + Role::RootlessCosmopolitan => "Rootless Cosmopolitan: loyal but may appear suspicious to Commissars.".to_string(), + Role::Spy => "Spy: 'spy ' to learn loyalties (risking exposure) and 'frame ' to seed incriminating evidence.".to_string(), + Role::TrotskyiteMastermind => "Trotskyite Mastermind: 'subvert ' nightly to recruit them. Failure may expose you.".to_string(), + Role::Stalin { .. } => "Stalin: 'verify' once to reveal yourself as the Vozhd, or 'rehabilitate ' to personally handle traitors.".to_string(), + }; + self.send_direct(format!("@{} Your role: {}", player, description)); + } + if saboteurs.len() > 1 { + for sab in &saboteurs { + let others: Vec<_> = saboteurs + .iter() + .filter(|s| *s != sab) + .map(|s| format!("@{}", s)) + .collect(); + self.send_direct(format!( + "@{} Fellow conspirators: {}. Use 'liquidate ' at night.", + sab, + if others.is_empty() { + "none".to_string() + } else { + others.join(", ") + } + )); + } + } + } + + fn handle_day_command(&mut self, player: &str, words: &[&str]) -> Reply { + if !self.is_alive(player) { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} You have already been purged.", player)); + return reply; + } + if words.is_empty() { + return self.day_help(player); + } + match words[0] { + "vote" => { + if words.len() < 2 { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} Vote for whom?", player)); + return reply; + } + self.handle_vote(player, words[1]) + } + "retract" => { + self.votes.remove(player); + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} You withdraw your vote.", player)); + reply + } + "status" => self.lobby_status(player), + "role" => self.role_reminder(player), + "help" => self.day_help(player), + _ => { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Unrecognised command during the day. Try 'vote ' or 'status'.", + player + )); + reply + } + } + } + + fn handle_vote(&mut self, player: &str, target: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if target.eq_ignore_ascii_case("none") + || target.eq_ignore_ascii_case("noone") + || target.eq_ignore_ascii_case("skip") + { + self.votes.insert(player.to_string(), None); + reply.push(format!("@{} You vote to stay the purges for now.", player)); + } else if !self.is_alive(target) { + reply.push(format!("@{} {} is not available to purge.", player, target)); + } else { + self.votes + .insert(player.to_string(), Some(target.to_string())); + reply.push(format!( + "@{} You accuse {} of wrecking the revolution!", + player, target + )); + } + self.check_day_resolution().unwrap_or(reply) + } + + fn check_day_resolution(&mut self) -> Option { + let alive: Vec<_> = self + .players + .iter() + .filter_map(|(p, state)| state.alive.then(|| p.clone())) + .collect(); + if alive.is_empty() { + return None; + } + // Majority check + let mut tally: HashMap, usize> = HashMap::new(); + for voter in &alive { + if let Some(choice) = self.votes.get(voter) { + *tally.entry(choice.clone()).or_default() += 1; + } + } + let majority = alive.len() / 2 + 1; + let mut leading: Option<(Option, usize)> = None; + for (candidate, count) in tally.iter() { + if Some(*count) >= Some(majority) { + leading = Some((candidate.clone(), *count)); + break; + } + } + if let Some((candidate, _)) = leading { + return Some(self.execute_day_result(candidate)); + } + if self.votes.len() == alive.len() { + // everyone voted -> resolve by plurality + let mut counts: Vec<(Option, usize)> = tally.into_iter().collect(); + counts.sort_by(|a, b| b.1.cmp(&a.1)); + if counts.is_empty() { + return Some(self.execute_day_result(None)); + } + if counts.len() > 1 && counts[0].1 == counts[1].1 { + return Some(self.execute_day_result(None)); + } + return Some(self.execute_day_result(counts[0].0.clone())); + } + None + } + + fn execute_day_result(&mut self, candidate: Option) -> Reply { + self.stage = Stage::Night(self.night + 1); + self.votes.clear(); + self.reset_night_state(); + self.framed_players.clear(); + let mut reply = Reply::new(); + if let Some(target) = candidate { + reply.push(format!("The soviet acts. {} is purged at sunset.", target)); + self.eliminate(&target, "purged by the soviet"); + } else { + reply.push("The soviet hesitates; no one is purged today.".to_string()); + } + self.check_victory(&mut reply); + if matches!(self.stage, Stage::Finished) { + return reply; + } + self.night += 1; + self.stage = Stage::Night(self.night); + reply.push(format!("Night {} descends on the Union.", self.night)); + self.schedule_reminder_if_needed(); + reply + } + + fn eliminate(&mut self, target: &str, reason: &str) { + let mut messages = Vec::new(); + if let Some(state) = self.players.get_mut(target) { + if state.alive { + state.alive = false; + let alignment = match state.alignment { + Alignment::Workers => "loyal", + Alignment::Saboteurs => "treacherous", + }; + let role_desc = Self::describe_role(&state.role); + messages.push(format!("{} was a {} ({}).", target, role_desc, reason)); + messages.push(format!("{} alignment was {}.", target, alignment)); + } + } + for message in messages { + self.emit_public(message); + } + } + + fn pending_saboteurs(&self) -> Vec { + if !matches!(self.stage, Stage::Night(_)) { + return Vec::new(); + } + self.players + .iter() + .filter_map(|(player, state)| { + if state.alive && state.alignment == Alignment::Saboteurs { + if self.saboteur_votes.contains_key(player) { + None + } else { + Some(player.clone()) + } + } else { + None + } + }) + .collect() + } + + fn cancel_reminder(&mut self) { + if let Some(handle) = self.reminder_task.take() { + handle.abort(); + } + } + + fn schedule_reminder_if_needed(&mut self) { + let waiting = self.pending_saboteurs(); + if waiting.is_empty() { + self.cancel_reminder(); + return; + } + self.cancel_reminder(); + let channel = self.channel.clone(); + self.reminder_task = Some(tokio::spawn(async move { + sleep(REMINDER_DELAY).await; + let mut reply = Reply::new(); + reply.quiet(); + for player in waiting { + reply.push(format!( + "@{} Reminder: the conspirators await your 'liquidate ' vote.", + player + )); + } + let _ = channel.send(GameEvent::Reply(reply)); + })); + } + + fn describe_role(role: &Role) -> &'static str { + match role { + Role::Worker => "worker", + Role::Saboteur => "Trotskyite saboteur", + Role::PoliticalCommissar => "political commissar", + Role::Chekist { .. } => "chekist", + Role::Militsioner => "militsioner", + Role::RootlessCosmopolitan => "rootless cosmopolitan", + Role::Spy => "spy", + Role::TrotskyiteMastermind => "Trotskyite mastermind", + Role::Stalin { .. } => "Comrade Stalin", + } + } + + fn check_victory(&mut self, reply: &mut Reply) { + let workers = self.worker_count(); + let saboteurs = self.saboteur_count(); + if saboteurs == 0 { + reply.push("The saboteurs have been liquidated. Soviet power endures!".to_string()); + self.stage = Stage::Finished; + } else if saboteurs >= workers { + reply.push("Trotskyite saboteurs seize parity and topple the soviet!".to_string()); + self.stage = Stage::Finished; + } + } + + fn day_help(&self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Day commands: 'vote ', 'vote none', 'retract', 'status', 'role'.", + player + )); + reply + } + + fn role_reminder(&self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if let Some(state) = self.players.get(player) { + reply.push(format!( + "@{} You are a {}.", + player, + Self::describe_role(&state.role) + )); + } else { + reply.push(format!( + "@{} You are not enrolled in the current game.", + player + )); + } + reply + } + + fn handle_night_command(&mut self, player: &str, words: &[&str]) -> Reply { + if !self.is_alive(player) { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} The dead cannot act at night.", player)); + return reply; + } + if words.is_empty() { + return self.night_help(player); + } + match words[0] { + "liquidate" => self.night_liquidate(player, words.get(1).copied()), + "investigate" => self.night_investigate(player, words.get(1).copied()), + "shoot" => self.night_shoot(player, words.get(1).copied()), + "unload" => self.night_unload(player), + "reload" => self.night_reload(player), + "guard" => self.night_guard(player, words.get(1).copied()), + "spy" => self.night_spy(player, words.get(1).copied()), + "frame" => self.night_frame(player, words.get(1).copied()), + "subvert" => self.night_subvert(player, words.get(1).copied()), + "verify" => self.night_verify(player), + "rehabilitate" => self.night_rehabilitate(player, words.get(1).copied()), + "status" => self.lobby_status(player), + "role" => self.role_reminder(player), + "help" => self.night_help(player), + _ => { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Unrecognised night command. Try 'help'.", + player + )); + reply + } + } + } + + fn night_help(&self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Night commands depend on your role: saboteurs 'liquidate', commissar 'investigate', chekist 'shoot/unload/reload', militsioner 'guard', spy 'spy/frame', mastermind 'subvert', Stalin 'verify/rehabilitate'.", + player + )); + reply + } + + fn night_liquidate(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !self.is_alignment(player, Alignment::Saboteurs) { + reply.push(format!( + "@{} Only the saboteurs coordinate liquidations.", + player + )); + return reply; + } + let choice = target.map(|t| t.to_string()); + if let Some(t) = &choice { + if t.eq_ignore_ascii_case("none") || t.eq_ignore_ascii_case("skip") { + self.saboteur_votes.insert(player.to_string(), None); + reply.push(format!("@{} You vote to stay your hand tonight.", player)); + } else if !self.is_alive(t) { + reply.push(format!("@{} {} is not a valid target.", player, t)); + return reply; + } else { + self.saboteur_votes + .insert(player.to_string(), Some(t.to_string())); + reply.push(format!("@{} You mark {} for liquidation.", player, t)); + } + } else { + reply.push(format!("@{} Specify a target or 'none'.", player)); + } + self.schedule_reminder_if_needed(); + self.check_night_resolution().unwrap_or(reply) + } + + fn is_alignment(&self, player: &str, alignment: Alignment) -> bool { + self.players + .get(player) + .map(|s| s.alive && s.alignment == alignment) + .unwrap_or(false) + } + + fn night_investigate(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::PoliticalCommissar) + ) { + reply.push(format!( + "@{} Only political commissars may interrogate at night.", + player + )); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown comrade {}.", player, target)); + } else { + self.commissar_target = Some(target.to_string()); + reply.push(format!("@{} You prepare dossiers on {}.", player, target)); + } + } else { + reply.push(format!("@{} Specify someone to investigate.", player)); + } + reply + } + + fn night_shoot(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::Chekist { .. }) + ) { + reply.push(format!("@{} Only the chekist carries the TT-33.", player)); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown target {}.", player, target)); + } else { + self.chekist_action = Some(ChekistOrder::Shoot(target.to_string())); + reply.push(format!( + "@{} You ready your TT-33 against {}.", + player, target + )); + } + } else { + reply.push(format!("@{} Specify whom to shoot.", player)); + } + reply + } + + fn night_unload(&mut self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::Chekist { .. }) + ) { + reply.push(format!("@{} Only the chekist handles the TT-33.", player)); + return reply; + } + self.chekist_action = Some(ChekistOrder::Unload); + reply.push(format!("@{} You carefully unload your TT-33.", player)); + reply + } + + fn night_reload(&mut self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::Chekist { .. }) + ) { + reply.push(format!("@{} Only the chekist handles the TT-33.", player)); + return reply; + } + self.chekist_action = Some(ChekistOrder::Reload); + reply.push(format!( + "@{} You spend the night readying your TT-33.", + player + )); + reply + } + + fn night_guard(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::Militsioner) + ) { + reply.push(format!("@{} Only the militsioner may guard.", player)); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown citizen {}.", player, target)); + } else { + self.militsioner_target = Some(target.to_string()); + reply.push(format!("@{} You stand guard over {}.", player, target)); + } + } else { + reply.push(format!("@{} Specify whom to guard.", player)); + } + reply + } + + fn night_spy(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!(self.players.get(player).map(|p| &p.role), Some(Role::Spy)) { + reply.push(format!( + "@{} Only spies can conduct clandestine inquiries.", + player + )); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown target {}.", player, target)); + } else { + self.spy_actions + .entry(player.to_string()) + .or_default() + .probe = Some(target.to_string()); + reply.push(format!( + "@{} You plant listening devices around {}.", + player, target + )); + } + } else { + reply.push(format!("@{} Specify whom to spy on.", player)); + } + reply + } + + fn night_frame(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!(self.players.get(player).map(|p| &p.role), Some(Role::Spy)) { + reply.push(format!("@{} Only spies can frame comrades.", player)); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown target {}.", player, target)); + } else { + self.spy_actions + .entry(player.to_string()) + .or_default() + .frame = Some(target.to_string()); + reply.push(format!( + "@{} You forge evidence against {}.", + player, target + )); + } + } else { + reply.push(format!("@{} Specify whom to frame.", player)); + } + reply + } + + fn night_subvert(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::TrotskyiteMastermind) + ) { + reply.push(format!( + "@{} Only the mastermind spreads counter-revolutionary poison.", + player + )); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown target {}.", player, target)); + } else { + self.mastermind_target = Some(target.to_string()); + reply.push(format!("@{} You whisper sedition to {}.", player, target)); + } + } else { + reply.push(format!("@{} Specify whom to subvert.", player)); + } + reply + } + + fn night_verify(&mut self, player: &str) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if let Some(Role::Stalin { revealed }) = self.players.get(player).map(|p| &p.role) { + if *revealed { + reply.push(format!( + "@{} The Vozhd has already declared himself.", + player + )); + } else { + self.stalin_action = Some(StalinOrder::Verify); + reply.push(format!( + "@{} You prepare to broadcast your credentials as Stalin.", + player + )); + } + } else { + reply.push(format!("@{} Only the Vozhd may radio Moscow.", player)); + } + reply + } + + fn night_rehabilitate(&mut self, player: &str, target: Option<&str>) -> Reply { + let mut reply = Reply::new(); + reply.quiet(); + if !matches!( + self.players.get(player).map(|p| &p.role), + Some(Role::Stalin { .. }) + ) { + reply.push(format!("@{} Only Stalin may order rehabilitation.", player)); + return reply; + } + if let Some(target) = target { + if !self.players.contains_key(target) { + reply.push(format!("@{} Unknown target {}.", player, target)); + } else { + self.stalin_action = Some(StalinOrder::Rehabilitate(target.to_string())); + reply.push(format!( + "@{} You arrange a midnight interview with {}.", + player, target + )); + } + } else { + reply.push(format!("@{} Specify whom to rehabilitate.", player)); + } + reply + } + + fn check_night_resolution(&mut self) -> Option { + // Night resolves once saboteurs have agreed on a target. + let saboteurs: Vec<_> = self + .players + .iter() + .filter_map(|(p, s)| { + (s.alive && s.alignment == Alignment::Saboteurs).then(|| p.clone()) + }) + .collect(); + if saboteurs.is_empty() { + return Some(self.resolve_night(None)); + } + let mut votes: HashMap, usize> = HashMap::new(); + for sab in &saboteurs { + if let Some(vote) = self.saboteur_votes.get(sab) { + *votes.entry(vote.clone()).or_default() += 1; + } + } + let target = votes.iter().max_by(|a, b| a.1.cmp(b.1)); + if let Some((choice, count)) = target { + if *count > saboteurs.len() / 2 { + return Some(self.resolve_night(Some(choice.clone()))); + } + } + None + } + + fn resolve_night(&mut self, target: Option>) -> Reply { + self.cancel_reminder(); + let mut reply = Reply::new(); + reply.push(format!("Dawn breaks over the Union.")); + let mut rng = rand::thread_rng(); + + // Process Stalin first if verifying. + if let Some(StalinOrder::Verify) = &self.stalin_action { + if let Some(state) = self + .players + .values_mut() + .find(|s| matches!(s.role, Role::Stalin { .. }) && s.alive) + { + if let Role::Stalin { revealed } = &mut state.role { + *revealed = true; + } + } + let stalin_name = self + .players + .iter() + .find_map(|(p, s)| matches!(s.role, Role::Stalin { .. }).then(|| p.clone())); + if let Some(name) = stalin_name { + reply.push(format!( + "Comrade Stalin reveals himself: {} is the Vozhd!", + name + )); + } + } + + // Resolve saboteur kill + if let Some(choice) = target.flatten() { + let mut victim = Some(choice); + // Militsioner guard + if let Some(guard_target) = self.militsioner_target.clone() { + let guard_alive = self + .players + .iter() + .find(|(_, s)| matches!(s.role, Role::Militsioner) && s.alive) + .map(|(p, _)| p.clone()); + if victim.as_ref() == Some(&guard_target) { + if let Some(guard) = guard_alive { + let roll: f64 = rng.gen(); + if roll < 0.6 { + reply.push(format!( + "{} dies defending {} from the saboteurs!", + guard, guard_target + )); + self.eliminate(&guard, "fell in defence of a comrade"); + victim = None; + } else if roll < 0.8 { + reply.push(format!( + "{} and {} both fall in a desperate struggle!", + guard, guard_target + )); + self.eliminate(&guard, "perished in a firefight"); + self.eliminate(&guard_target, "killed despite protection"); + victim = None; + } else { + reply.push(format!( + "{} fails to protect {} from the saboteurs.", + guard, guard_target + )); + } + } + } + } + if let Some(victim) = victim { + self.eliminate(&victim, "assassinated at night"); + } + } + + // Commissar investigation result + if let Some(target) = self.commissar_target.clone() { + if let Some(result) = self.commissar_report(&target) { + self.send_direct(result); + } + } + + // Spy actions + for (spy, actions) in self.spy_actions.clone() { + if !self.is_alive(&spy) { + continue; + } + if let Some(frame) = actions.frame { + self.framed_players.insert(frame.clone()); + self.send_direct(format!( + "@{} You plant Trotskyite pamphlets in {}'s desk.", + spy, frame + )); + } + if let Some(probe) = actions.probe { + self.resolve_spy_probe(&mut reply, &spy, &probe); + } + } + + // Chekist action + if let Some(order) = self.chekist_action.clone() { + self.resolve_chekist(order, &mut reply); + } + + // Mastermind action + if let Some(target) = self.mastermind_target.clone() { + self.resolve_mastermind(&mut reply, &target); + } + + // Stalin rehabilitate + if let Some(StalinOrder::Rehabilitate(target)) = self.stalin_action.clone() { + self.resolve_rehabilitation(&mut reply, &target); + } + + if !self.framed_players.is_empty() { + let rumour: Vec = self + .framed_players + .iter() + .map(|p| format!("@{}", p)) + .collect(); + reply.push(format!( + "Whispers spread about suspicious pamphlets found near {}.", + rumour.join(", ") + )); + } + + // Clear night state + self.reset_night_state(); + + if matches!(self.stage, Stage::Finished) { + return reply; + } + self.day += 1; + self.stage = Stage::Day(self.day); + reply.push(format!("Day {} begins.", self.day)); + self.check_victory(&mut reply); + reply + } + + fn commissar_report(&self, target: &str) -> Option { + let state = self.players.get(target)?; + let mut rng = rand::thread_rng(); + let mut hostile = matches!(state.alignment, Alignment::Saboteurs); + if matches!(state.role, Role::RootlessCosmopolitan) { + if rng.gen::() < 0.8 { + hostile = true; + } + } + if !hostile && rng.gen::() < 0.15 { + hostile = true; + } + let assessment = if hostile { + "suspected enemy of the people" + } else { + "loyal Marxist-Leninist" + }; + Some(format!( + "@{} Commissar report: {} is a {}.", + self.find_role_holder(Role::PoliticalCommissar)?, + target, + assessment + )) + } + + fn find_role_holder(&self, role: Role) -> Option { + for (player, state) in &self.players { + if state.alive && std::mem::discriminant(&state.role) == std::mem::discriminant(&role) { + return Some(player.clone()); + } + } + None + } + + fn resolve_spy_probe(&mut self, reply: &mut Reply, spy: &str, target: &str) { + if !self.is_alive(spy) { + return; + } + let mut rng = rand::thread_rng(); + if rng.gen::() < 0.12 { + reply.push(format!( + "{} is exposed as a foreign spy and disappears into the Lubyanka!", + spy + )); + self.eliminate(spy, "exposed as spy"); + return; + } + let target_state = match self.players.get(target) { + Some(state) if state.alive => state, + _ => { + self.send_direct(format!( + "@{} Your wires pick up only silence; {} is gone.", + spy, target + )); + return; + } + }; + let hostility = match target_state.alignment { + Alignment::Saboteurs => "Trotskyite conspirator", + Alignment::Workers => "loyal Soviet", + }; + self.send_direct(format!("@{} Intel: {} is a {}.", spy, target, hostility)); + if matches!(target_state.alignment, Alignment::Saboteurs) { + if let Some(spy_state) = self.players.get_mut(spy) { + spy_state.alignment = Alignment::Saboteurs; + spy_state.role = Role::Saboteur; + self.send_direct(format!( + "@{} You are coerced into the Trotskyite network!", + spy + )); + } + } + } + + fn resolve_chekist(&mut self, order: ChekistOrder, reply: &mut Reply) { + let chekist_name = match self + .players + .iter() + .find(|(_, s)| matches!(s.role, Role::Chekist { .. }) && s.alive) + { + Some((name, _)) => name.clone(), + None => return, + }; + + match order { + ChekistOrder::Unload => { + if let Some(state) = self.players.get_mut(&chekist_name) { + if let Role::Chekist { loaded, .. } = &mut state.role { + *loaded = false; + } + } + reply.push(format!("{} unloads their TT-33 for safety.", chekist_name)); + } + ChekistOrder::Reload => { + if let Some(state) = self.players.get_mut(&chekist_name) { + if let Role::Chekist { loaded, .. } = &mut state.role { + *loaded = true; + } + } + reply.push(format!("{} readies the TT-33 for duty.", chekist_name)); + } + ChekistOrder::Shoot(target) => { + let mut rng = rand::thread_rng(); + if let Some(state) = self.players.get_mut(&chekist_name) { + if let Role::Chekist { ammo, loaded } = &mut state.role { + if *ammo == 0 { + reply.push(format!( + "{} pulls the trigger but the magazine is empty!", + chekist_name + )); + return; + } + if !*loaded { + reply.push(format!("{} fumbles with an unloaded TT-33!", chekist_name)); + return; + } + *ammo -= 1; + let misfire = rng.gen::() < 0.2; + if misfire { + let victims = self.alive_players(); + if victims.is_empty() { + return; + } + let accidental = victims + .choose(&mut rng) + .cloned() + .unwrap_or(chekist_name.clone()); + reply.push(format!( + "{}'s TT-33 misfires wildly, striking {}!", + chekist_name, accidental + )); + self.eliminate(&accidental, "caught by a stray bullet"); + return; + } + } + } + if !self.is_alive(&target) { + reply.push(format!( + "{} searches for {}, but they are already gone.", + chekist_name, target + )); + return; + } + reply.push(format!( + "{} executes {} with revolutionary fervour!", + chekist_name, target + )); + self.eliminate(&target, "shot by the chekist"); + } + } + } + + fn resolve_mastermind(&mut self, reply: &mut Reply, target: &str) { + if !self.is_alive(target) { + return; + } + let mut rng = rand::thread_rng(); + let success_rate = if self + .players + .get(target) + .map(|s| s.alignment == Alignment::Workers) + .unwrap_or(false) + { + 0.6 + } else { + 0.25 + }; + if rng.gen::() < success_rate { + if let Some(state) = self.players.get_mut(target) { + state.alignment = Alignment::Saboteurs; + state.role = Role::Saboteur; + reply.push(format!( + "{} succumbs to counter-revolutionary rhetoric and joins the saboteurs!", + target + )); + } + } else if rng.gen::() < 0.3 { + reply.push(format!( + "The mastermind is exposed while courting {}! Revolutionary vigilance rises.", + target + )); + } + } + + fn resolve_rehabilitation(&mut self, reply: &mut Reply, target: &str) { + if !self.is_alive(target) { + return; + } + let mut rng = rand::thread_rng(); + let target_alignment = self + .players + .get(target) + .map(|p| p.alignment) + .unwrap_or(Alignment::Workers); + if target_alignment == Alignment::Saboteurs { + reply.push(format!( + "Stalin personally rehabilitates {}... straight to the gulag!", + target + )); + self.eliminate(target, "removed by Stalin"); + } else { + reply.push(format!( + "Stalin interrogates {}, but the debate is perilous...", + target + )); + if rng.gen::() < 0.25 { + let stalin = self + .players + .iter() + .find_map(|(p, s)| matches!(s.role, Role::Stalin { .. }).then(|| p.clone())); + if let Some(stalin) = stalin { + reply.push(format!( + "{} collapses from exhaustion and is out of the game!", + stalin + )); + self.eliminate(&stalin, "perished after overwork"); + } + } + } + } + + fn handle_player_command(&mut self, sender: &str, content: &str) -> Reply { + let trimmed = content.trim(); + if trimmed.is_empty() { + return match self.stage { + Stage::Lobby | Stage::Finished => self.lobby_status(sender), + Stage::Day(_) => self.day_help(sender), + Stage::Night(_) => self.night_help(sender), + }; + } + let lower = trimmed.to_lowercase(); + let parts: Vec<&str> = lower.split_whitespace().collect(); + if !self.command_is_relevant(&parts) { + return Reply::new(); + } + match self.stage { + Stage::Lobby => self.handle_lobby_command(sender, &parts), + Stage::Day(_) => self.handle_day_command(sender, &parts), + Stage::Night(_) => self.handle_night_command(sender, &parts), + Stage::Finished => { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} The last Soviet game has concluded. Use 'soviet' to queue again.", + sender + )); + reply + } + } + } + + fn handle_lobby_command(&mut self, player: &str, words: &[&str]) -> Reply { + if words.is_empty() { + return self.lobby_status(player); + } + match words[0] { + "soviet" if words.len() == 1 => self.join_lobby(player), + "soviet" if words.len() >= 2 && words[1] == "start" => self.start_game(player), + "soviet" if words.len() >= 2 && words[1] == "leave" => self.leave_lobby(player), + "leave" if words.len() >= 2 && words[1] == "soviet" => self.leave_lobby(player), + "status" => self.lobby_status(player), + "help" => { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!( + "@{} Lobby commands: 'soviet', 'soviet start', 'soviet leave', 'status'.", + player + )); + reply + } + _ => { + let mut reply = Reply::new(); + reply.quiet(); + reply.push(format!("@{} Unknown lobby command. Try 'soviet'.", player)); + reply + } + } + } +} + +#[derive(Serialize, Deserialize)] +struct SovietMatchState { + lobby: Vec, + players: HashMap, + stage: Stage, + day: u32, + night: u32, + votes: HashMap>, + saboteur_votes: HashMap>, + commissar_target: Option, + chekist_action: Option, + militsioner_target: Option, + spy_actions: HashMap, + mastermind_target: Option, + stalin_action: Option, + framed_players: Vec, +} + +pub struct SovietMafia { + matches: HashMap, + player_matches: HashMap, + channel: mpsc::UnboundedSender, + token: CancellationToken, + next_match_id: usize, +} + +impl SovietMafia { + fn create_match(&mut self) -> usize { + let match_id = self.next_match_id; + self.next_match_id = self.next_match_id.saturating_add(1); + let instance = SovietMatch::new(self.channel.clone(), self.token.clone()); + self.matches.insert(match_id, instance); + match_id + } + + fn find_or_create_lobby(&mut self) -> usize { + if let Some((&match_id, _)) = self + .matches + .iter() + .find(|(_, instance)| matches!(instance.stage(), Stage::Lobby)) + { + match_id + } else { + self.create_match() + } + } + + fn handle_assigned_player(&mut self, match_id: usize, command: &PlayerCommand) -> Reply { + if let Some(instance) = self.matches.get_mut(&match_id) { + let reply = instance.handle_player_command(&command.sender, &command.content); + self.sync_match(match_id); + reply + } else { + self.player_matches.remove(&command.sender); + Reply::new() + } + } + + fn handle_unassigned_player(&mut self, command: &PlayerCommand) -> Reply { + let first_word = command.content.split_whitespace().next().unwrap_or(""); + let should_join_lobby = + first_word.is_empty() || matches!(first_word, "soviet" | "status" | "help" | "leave"); + if !should_join_lobby { + return Reply::new(); + } + let match_id = self.find_or_create_lobby(); + if let Some(instance) = self.matches.get_mut(&match_id) { + let reply = instance.handle_player_command(&command.sender, &command.content); + self.sync_match(match_id); + reply + } else { + Reply::new() + } + } + + fn sync_match(&mut self, match_id: usize) { + let (stage, participants) = if let Some(instance) = self.matches.get(&match_id) { + (instance.stage(), instance.participant_names()) + } else { + self.player_matches.retain(|_, id| *id != match_id); + return; + }; + + let participants_set: HashSet = participants.iter().cloned().collect(); + self.player_matches.retain(|player, id| { + if *id != match_id { + return true; + } + participants_set.contains(player) + }); + + if matches!(stage, Stage::Finished) { + for player in participants { + self.player_matches.remove(&player); + } + self.matches.remove(&match_id); + } else { + for player in participants { + self.player_matches.insert(player, match_id); + } + } + } +} + +#[derive(Serialize, Deserialize)] +struct SovietMatchEntry { + id: usize, + state: SovietMatchState, +} + +#[derive(Serialize, Deserialize)] +struct SovietState { + next_match_id: usize, + matches: Vec, +} + +impl Game for SovietMafia { + fn new(c: mpsc::UnboundedSender, token: CancellationToken) -> Self { + let mut mafia = SovietMafia { + matches: HashMap::new(), + player_matches: HashMap::new(), + channel: c, + token, + next_match_id: 0, + }; + mafia.create_match(); + mafia + } + + fn next(&mut self, command: &Command) -> Reply { + match command { + Command::PlayerCommand(player) => { + if let Some(&match_id) = self.player_matches.get(&player.sender) { + let reply = self.handle_assigned_player(match_id, player); + if reply.0.is_empty() { + // If the player tried to use a finished game, allow reprocessing + if !self.player_matches.contains_key(&player.sender) { + return self.handle_unassigned_player(player); + } + } + reply + } else { + self.handle_unassigned_player(player) + } + } + Command::InternalCommand(_) => Reply::new(), + } + } + + fn name(&self) -> &'static str { + "soviet" + } + + fn save_state(&self) -> Option { + let matches: Vec = self + .matches + .iter() + .map(|(id, m)| SovietMatchEntry { + id: *id, + state: m.to_state(), + }) + .collect(); + let state = SovietState { + next_match_id: self.next_match_id, + matches, + }; + Value::try_from(state).ok() + } + + fn load_state(&mut self, value: &Value) -> Result<(), String> { + let state: SovietState = value.clone().try_into().map_err(|e| e.to_string())?; + self.matches.clear(); + self.player_matches.clear(); + + let mut max_id = 0usize; + let mut loaded_any = false; + for entry in state.matches { + loaded_any = true; + let mut instance = SovietMatch::new(self.channel.clone(), self.token.clone()); + instance.apply_state(entry.state); + max_id = max_id.max(entry.id); + self.matches.insert(entry.id, instance); + self.sync_match(entry.id); + } + + if !loaded_any { + self.matches.clear(); + self.player_matches.clear(); + self.next_match_id = 0; + let id = self.create_match(); + self.sync_match(id); + return Ok(()); + } + + self.next_match_id = state.next_match_id.max(max_id.saturating_add(1)); + + Ok(()) + } +} + +#[cfg(test)] +impl SovietMafia { + fn player_match_id(&self, player: &str) -> Option { + self.player_matches.get(player).copied() + } + + fn match_count(&self) -> usize { + self.matches.len() + } +} + +#[cfg(test)] +mod test { + use super::{SovietMafia, MIN_PLAYERS}; + use crate::game::{Command, Game, PlayerCommand, Reply}; + use crate::CancellationToken; + use tokio::sync::mpsc; + + fn dispatch(game: &mut dyn Game, sender: &str, content: &str) -> Reply { + let command = PlayerCommand { + sender: sender.to_string(), + content: content.to_string(), + }; + game.next(&Command::PlayerCommand(&command)) + } + + #[tokio::test] + async fn test_concurrent_soviet_games() { + let mut game = SovietMafia::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let first_group: Vec = (1..=MIN_PLAYERS) + .map(|i| format!("alpha{}@node", i)) + .collect(); + + for player in &first_group { + dispatch(&mut game, player, "soviet"); + } + dispatch(&mut game, &first_group[0], "soviet start"); + + let first_match = first_group + .iter() + .filter_map(|player| game.player_match_id(player)) + .next() + .expect("expected first match id"); + + let second_group: Vec = (1..=MIN_PLAYERS) + .map(|i| format!("beta{}@node", i)) + .collect(); + + for player in &second_group { + dispatch(&mut game, player, "soviet"); + } + + let second_match = second_group + .iter() + .filter_map(|player| game.player_match_id(player)) + .next() + .expect("expected second match id"); + + assert_ne!( + first_match, second_match, + "players should be routed to different matches" + ); + assert!(game.match_count() >= 2); + } +} Index: src/spoof.rs ================================================================== --- src/spoof.rs +++ src/spoof.rs @@ -1,368 +1,626 @@ -use crate::{ - game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}, - sleep, Duration, Lazy, Regex, -}; +use crate::game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}; +use crate::CancellationToken; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -/// Spoof. +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::sleep; +use toml::Value; + +const REMINDER_DELAY: Duration = Duration::from_secs(300); +const LOBBY_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_PLAYERS: usize = 10; +const MIN_PLAYERS_FOR_TIMEOUT: usize = 2; -/// A match. Considering to move this to Game. -/// TODO: think about locales. +/// Representation of an ongoing Spoof match. +#[derive(Clone, Serialize, Deserialize)] struct Match { - state: HashMap>, + /// Map of player account -> optional guess value. + state: HashMap>, + /// Coins each player decided to keep. + coins: HashMap, + /// Track used guesses to enforce uniqueness. + guesses: HashSet, } impl Match { - fn new(h: &HashSet) -> Self { - let mut n = HashMap::new(); - for key in h { - n.insert(key.clone(), None); + fn new(players: &HashSet) -> Self { + let mut state = HashMap::new(); + let mut coins = HashMap::new(); + for player in players { + state.insert(player.clone(), None); + coins.insert(player.clone(), 0); + } + Match { + state, + coins, + guesses: HashSet::new(), } - Match { state: n } + } + + fn participants(&self) -> Vec { + self.state.keys().cloned().collect() + } + + fn waiting_players(&self) -> Vec { + self.state + .iter() + .filter_map(|(player, guess)| guess.is_none().then(|| player.clone())) + .collect() } fn is_ready(&self) -> bool { self.state.values().all(|v| v.is_some()) } + + fn total_coins(&self) -> u16 { + self.coins.values().sum() + } + + fn reveal_and_resolve(&self) -> Reply { + let mut reply = Reply::new(); + let total = self.total_coins(); + let mut best_distance = u16::MAX; + let mut winner: Option = None; + + for (player, guess) in &self.state { + let guess = guess.expect("All players must have guessed before resolving"); + let distance = guess.abs_diff(total); + match distance.cmp(&best_distance) { + std::cmp::Ordering::Less => { + best_distance = distance; + winner = Some(player.clone()); + } + std::cmp::Ordering::Equal => { + winner = None; + } + std::cmp::Ordering::Greater => {} + } + } + + if let Some(winner_name) = winner { + reply.push(format!( + "@{} wins! Their guess was closest to the total coins: {} (actual total {}).", + winner_name, best_distance, total + )); + } else { + reply.push(format!( + "No one guessed the total of {} coins closely enough. It's a draw!", + total + )); + } + + reply + } } -/// 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 can fit in the lobby. capacity: u8, - /// A vector of ongoing matches. - matches: Vec, - /// HashSet indicating for each player which match they are in. + matches: HashMap, 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, + channel: mpsc::UnboundedSender, + token: CancellationToken, + pending_reminders: HashSet, + next_match_id: usize, + pending_lobby_timeout: Option, + lobby_timeout_generation: u64, } 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."); + fn schedule_reminder(&mut self, match_id: usize) { + if !self.pending_reminders.insert(match_id) { + return; + } + let sender = self.channel.clone(); + let token = self.token.clone(); + tokio::spawn(async move { + tokio::select! { + _ = token.cancelled() => {} + _ = sleep(REMINDER_DELAY) => { + let _ = sender.send(GameEvent::Step(InternalCommand { + name: "spoof".to_string(), + match_index: match_id, + command: "remind".to_string(), + })); + } + } + }); + } + + fn schedule_lobby_timeout(&mut self) { + if self.lobby.len() < MIN_PLAYERS_FOR_TIMEOUT { + self.pending_lobby_timeout = None; + return; + } + + self.lobby_timeout_generation = self.lobby_timeout_generation.wrapping_add(1); + let generation = self.lobby_timeout_generation; + self.pending_lobby_timeout = Some(generation); + let sender = self.channel.clone(); + let token = self.token.clone(); + tokio::spawn(async move { + tokio::select! { + _ = token.cancelled() => {} + _ = sleep(LOBBY_TIMEOUT) => { + let _ = sender.send(GameEvent::Step(InternalCommand { + name: "spoof".to_string(), + match_index: generation as usize, + command: "start_lobby".to_string(), + })); + } + } + }); + } + + fn start_lobby_match(&mut self) -> Option { + if self.lobby.len() < MIN_PLAYERS_FOR_TIMEOUT { + return None; + } + + let players = self.lobby.clone(); + self.lobby.clear(); + self.pending_lobby_timeout = None; + self.capacity = MAX_PLAYERS as u8; + + let match_id = self.next_match_id; + self.next_match_id += 1; + + let new_match = Match::new(&players); + for player in &players { + self.players.insert(player.clone(), match_id); + } + self.matches.insert(match_id, new_match); + + let mut reply = Reply::new(); + reply.quiet(); + for player in players { + reply.push(format!( + "@{} The spoof match has started. First, send 'keep ' to choose your coins, then 'guess ' to guess the overall sum.", + player + )); + } + self.schedule_reminder(match_id); + Some(reply) + } + + fn player(&mut self, m: &PlayerCommand) -> Reply { + let mut reply = Reply::new(); + let waiting = self.lobby.contains(&m.sender); + let playing = self.players.contains_key(&m.sender); + let words: Vec<_> = m.content.split_whitespace().collect(); + + match words[..] { + ["help"] => { + reply.quiet(); + if let Some(&match_id) = self.players.get(&m.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + reply.push(format!( + "@{} You're in a spoof match. Use 'keep ' to choose your coins, then 'guess ' for the total. Each guess must be unique.", + m.sender + )); + let waiting_players = current_match.waiting_players(); + if waiting_players.contains(&m.sender) { + reply.push("You still need to submit your guess.".to_string()); + } else if !waiting_players.is_empty() { + reply.push(format!( + "Waiting for guesses from: {}.", + waiting_players.join(", ") + )); + } + } else { + reply.push("Your previous spoof match has ended.".to_string()); + self.players.remove(&m.sender); + } + } else if waiting { + let lobby_size = self.lobby.len(); + reply.push(format!( + "@{} You're queued for spoof with {} player(s). We'll start once we reach {} players or after {} minutes without new arrivals.", + m.sender, + lobby_size, + MAX_PLAYERS, + LOBBY_TIMEOUT.as_secs() / 60 + )); + } else { + reply.push( + "Send 'spoof' to join the lobby, then 'keep ' and 'guess ' once a match begins.".to_string(), + ); + } + } + ["spoof"] => { + if waiting { + reply.push(format!( + "@{} You're already waiting for a game of spoof. Be patient.", + m.sender + )); + } else if playing { + reply.push(format!( + "@{} You're already playing a game of spoof.", + m.sender + )); + } else { + self.lobby.insert(m.sender.clone()); + let lobby_size = self.lobby.len(); + + if lobby_size >= MAX_PLAYERS { + if let Some(start_reply) = self.start_lobby_match() { + return start_reply; + } + } else { + let remaining_slots = MAX_PLAYERS.saturating_sub(lobby_size); + self.capacity = remaining_slots as u8; + + if lobby_size == 1 { + reply.push(format!( + "@{} You are now waiting for a game of spoof. I'll start once at least {} players are ready or after {} minutes without new players (up to {} players).", + m.sender, + MIN_PLAYERS_FOR_TIMEOUT, + LOBBY_TIMEOUT.as_secs() / 60, + MAX_PLAYERS + )); + } else { + reply.push(format!( + "@{} You have joined the spoof lobby. {} players are waiting. I'll start after {} minutes without new players or when we reach {} players.", + m.sender, + lobby_size, + LOBBY_TIMEOUT.as_secs() / 60, + MAX_PLAYERS + )); + } + + if lobby_size >= MIN_PLAYERS_FOR_TIMEOUT { + self.schedule_lobby_timeout(); + } + } + } + } + ["keep", coins] => { + if !playing { + reply.push(format!("@{} You must join a game first!", m.sender)); + } else if let Some(&match_id) = self.players.get(&m.sender) { + if let Some(current_match) = self.matches.get_mut(&match_id) { + let coins = coins.parse::().unwrap_or(0); + current_match.coins.insert(m.sender.clone(), coins); + reply.push(format!( + "You have kept {} coins. When you are ready, send 'guess ' to make your prediction.", + coins + )); + } else { + reply.push("Your match has already ended.".to_string()); + self.players.remove(&m.sender); + } + } + } + ["guess", guess] => { + if !playing { + reply.push(format!("@{} You must join a game first!", m.sender)); + } else if let Some(&match_id) = self.players.get(&m.sender) { + match self.matches.get_mut(&match_id) { + Some(current_match) => { + let guess_val = guess.parse::().unwrap_or(0); + if current_match.guesses.contains(&guess_val) { + reply.push( + "That guess has already been made. Please choose a different number.". + to_string(), + ); + } else { + current_match + .state + .insert(m.sender.clone(), Some(guess_val)); + current_match.guesses.insert(guess_val); + if current_match.is_ready() { + let participants = current_match.participants(); + let resolution = current_match.reveal_and_resolve(); + self.matches.remove(&match_id); + self.pending_reminders.remove(&match_id); + for participant in participants { + self.players.remove(&participant); + } + reply = resolution; + } else { + reply.push("You have guessed.".to_string()); + // schedule another reminder cycle for remaining players + self.pending_reminders.remove(&match_id); + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } + } + None => { + reply.push("Your match has already ended.".to_string()); + self.players.remove(&m.sender); + } } } } _ => {} } - r + + reply } - /// Handling an internal command. fn internal(&mut self, m: &InternalCommand) -> Reply { - let mut r = Reply::new(); - if m.name == self.name {} - r + if m.name != "spoof" { + return Reply::new(); + } + match m.command.as_str() { + "remind" => { + self.pending_reminders.remove(&m.match_index); + let waiting_players = self + .matches + .get(&m.match_index) + .map(|current_match| current_match.waiting_players()) + .unwrap_or_default(); + + if waiting_players.is_empty() { + return Reply::new(); + } + + let mut reply = Reply::new(); + reply.quiet(); + for player in waiting_players { + reply.push(format!( + "@{} Reminder: we still need your 'keep ' and 'guess ' commands for spoof.", + player + )); + } + self.schedule_reminder(m.match_index); + reply + } + "start_lobby" => { + let generation = m.match_index as u64; + if self.pending_lobby_timeout != Some(generation) { + return Reply::new(); + } + + self.pending_lobby_timeout = None; + self.start_lobby_match().unwrap_or_else(Reply::new) + } + _ => Reply::new(), + } } } 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 new(c: mpsc::UnboundedSender, token: CancellationToken) -> Self { + Spoof { + lobby: HashSet::new(), + capacity: MAX_PLAYERS as u8, + matches: HashMap::new(), + players: HashMap::new(), + channel: c, + token, + pending_reminders: HashSet::new(), + next_match_id: 0, + pending_lobby_timeout: None, + lobby_timeout_generation: 0, + } + } + + fn next(&mut self, m: &Command) -> Reply { + match m { + Command::PlayerCommand(p) => self.player(p), + Command::InternalCommand(i) => self.internal(i), + } + } + + fn name(&self) -> &'static str { + "spoof" + } + + fn save_state(&self) -> Option { + let lobby: Vec = self.lobby.iter().cloned().collect(); + let matches: Vec = self + .matches + .iter() + .map(|(id, m)| SpoofMatchEntry { + id: *id, + state: m.clone(), + }) + .collect(); + let state = SpoofState { + lobby, + capacity: self.capacity, + matches, + next_match_id: self.next_match_id, + }; + Value::try_from(state).ok() + } + + fn load_state(&mut self, value: &Value) -> Result<(), String> { + let state: SpoofState = value.clone().try_into().map_err(|e| e.to_string())?; + self.lobby = state.lobby.into_iter().collect(); + self.capacity = state.capacity; + self.matches = state + .matches + .into_iter() + .map(|entry| (entry.id, entry.state)) + .collect(); + self.players.clear(); + for (match_id, m) in &self.matches { + for player in m.state.keys() { + self.players.insert(player.clone(), *match_id); + } + } + self.next_match_id = state.next_match_id; + self.pending_reminders.clear(); + for match_id in self.matches.keys().copied().collect::>() { + if let Some(current_match) = self.matches.get(&match_id) { + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } + self.pending_lobby_timeout = None; + self.lobby_timeout_generation = 0; + if self.lobby.len() >= MIN_PLAYERS_FOR_TIMEOUT { + self.schedule_lobby_timeout(); + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct SpoofMatchEntry { + id: usize, + state: Match, +} + +#[derive(Serialize, Deserialize)] +struct SpoofState { + lobby: Vec, + capacity: u8, + matches: Vec, + next_match_id: usize, +} + +#[cfg(test)] +impl Spoof { + fn pending_lobby_timeout_id(&self) -> Option { + self.pending_lobby_timeout + } +} + +#[cfg(test)] +mod test { + use super::{LOBBY_TIMEOUT, MAX_PLAYERS}; + use crate::game::{Command, Game, InternalCommand, PlayerCommand, Reply}; + use crate::spoof::Spoof; + use crate::CancellationToken; + use tokio::sync::mpsc; 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] - ); + g.next(&Command::PlayerCommand(&p)) + } + + #[tokio::test] + async fn test_new() { + let g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new()); + assert_eq!(g.capacity, MAX_PLAYERS as u8); + assert_eq!(g.lobby.len(), 0); + assert!(g.matches.is_empty()); + } + + #[tokio::test] + async fn test_join_game() { + let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let c1 = ("modulux@node.isonomia.net".to_string(), "spoof".to_string()); + let r1 = command(&mut g, c1); + assert!(r1 + .0 + .iter() + .any(|msg| msg.contains("You are now waiting for a game of spoof."))); + assert!(r1 + .0 + .iter() + .any(|msg| msg.contains(&format!("after {} minutes", LOBBY_TIMEOUT.as_secs() / 60)))); + } + + #[tokio::test] + async fn test_play_and_guess() { + let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let p1 = "modulux@node.isonomia.net".to_string(); + let p2 = "modulux2@node.isonomia.net".to_string(); + command(&mut g, (p1.clone(), "spoof".to_string())); + command(&mut g, (p2.clone(), "spoof".to_string())); + let lobby_id = g + .pending_lobby_timeout_id() + .expect("expected lobby timeout to be scheduled"); + let start_command = InternalCommand { + name: "spoof".to_string(), + match_index: lobby_id as usize, + command: "start_lobby".to_string(), + }; + let start_reply = g.next(&Command::InternalCommand(start_command)); + assert!(start_reply + .0 + .iter() + .all(|msg| msg.contains("The spoof match has started"))); + + let keep_reply = command(&mut g, (p1.clone(), "keep 3".to_string())) + .0 + .join("\n"); + assert!(keep_reply.contains("When you are ready, send 'guess '")); + command(&mut g, (p2.clone(), "keep 4".to_string())); + let r = command(&mut g, (p1.clone(), "guess 7".to_string())); + assert!(r.0.contains(&"You have guessed.".to_string())); + + let reminder = InternalCommand { + name: "spoof".to_string(), + match_index: 0, + command: "remind".to_string(), + }; + let reminder_reply = g.next(&Command::InternalCommand(reminder)); + assert!(reminder_reply.0.iter().any(|msg| msg.contains("Reminder"))); + } + + #[tokio::test] + async fn test_help_during_match() { + let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let p1 = "modulux@node.isonomia.net".to_string(); + let p2 = "modulux2@node.isonomia.net".to_string(); + command(&mut g, (p1.clone(), "spoof".to_string())); + command(&mut g, (p2.clone(), "spoof".to_string())); + let lobby_id = g + .pending_lobby_timeout_id() + .expect("expected lobby timeout to be scheduled"); + let start_command = InternalCommand { + name: "spoof".to_string(), + match_index: lobby_id as usize, + command: "start_lobby".to_string(), + }; + let _ = g.next(&Command::InternalCommand(start_command)); + + let help_reply = command(&mut g, (p1.clone(), "help".to_string())); + assert!(help_reply.0.iter().any(|msg| msg.contains("keep "))); + } + + #[tokio::test] + async fn test_parallel_matches() { + let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let players = vec![ + "modulux@node.isonomia.net".to_string(), + "modulux2@node.isonomia.net".to_string(), + "modulux3@node.isonomia.net".to_string(), + "modulux4@node.isonomia.net".to_string(), + ]; + + // First match + command(&mut g, (players[0].clone(), "spoof".to_string())); + command(&mut g, (players[1].clone(), "spoof".to_string())); + let first_lobby = g + .pending_lobby_timeout_id() + .expect("first lobby should schedule a timeout"); + let start_first = InternalCommand { + name: "spoof".to_string(), + match_index: first_lobby as usize, + command: "start_lobby".to_string(), + }; + g.next(&Command::InternalCommand(start_first)); + + // Second match + command(&mut g, (players[2].clone(), "spoof".to_string())); + command(&mut g, (players[3].clone(), "spoof".to_string())); + let second_lobby = g + .pending_lobby_timeout_id() + .expect("second lobby should schedule a timeout"); + let start_second = InternalCommand { + name: "spoof".to_string(), + match_index: second_lobby as usize, + command: "start_lobby".to_string(), + }; + g.next(&Command::InternalCommand(start_second)); + + assert_eq!(g.matches.len(), 2); + assert_eq!(g.players.len(), 4); + assert!(g + .players + .get(&players[0]) + .zip(g.players.get(&players[2])) + .map(|(a, b)| a != b) + .unwrap_or(false)); } } ADDED src/twothirds.rs Index: src/twothirds.rs ================================================================== --- /dev/null +++ src/twothirds.rs @@ -0,0 +1,489 @@ +use crate::game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}; +use crate::CancellationToken; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::sleep; +use toml::Value; + +const REMINDER_DELAY: Duration = Duration::from_secs(300); + +#[derive(Clone, Serialize, Deserialize)] +struct Match { + guesses: HashMap>, +} + +impl Match { + fn new(players: &HashSet) -> Self { + let guesses = players.iter().map(|p| (p.clone(), None)).collect(); + Match { guesses } + } + + fn waiting_players(&self) -> Vec { + self.guesses + .iter() + .filter_map(|(player, guess)| guess.is_none().then(|| player.clone())) + .collect() + } + + fn is_ready(&self) -> bool { + self.guesses.values().all(|guess| guess.is_some()) + } + + fn resolve(&self) -> Reply { + let mut reply = Reply::new(); + let completed: Vec<(&String, f64)> = self + .guesses + .iter() + .filter_map(|(player, guess)| guess.map(|value| (player, value))) + .collect(); + + let average: f64 = + completed.iter().map(|(_, value)| value).sum::() / completed.len() as f64; + let target = (2.0 * average) / 3.0; + + let mut winner: Option<(&String, f64)> = None; + let mut best_distance = f64::MAX; + + for (player, guess) in &completed { + let distance = (guess - target).abs(); + match distance.partial_cmp(&best_distance) { + Some(std::cmp::Ordering::Less) => { + winner = Some((player, *guess)); + best_distance = distance; + } + Some(std::cmp::Ordering::Equal) => winner = None, + _ => {} + } + } + + let summary: Vec = completed + .iter() + .map(|(player, value)| format!("@{} → {:.2}", player, value)) + .collect(); + + match winner { + Some((player, guess)) => { + reply.push(format!( + "@{} wins the two-thirds game with {:.2}. Target was {:.2}. Results: {}", + player, + guess, + target, + summary.join(", ") + )); + } + None => { + reply.push(format!( + "No unique winner for the two-thirds game. Target was {:.2}. Results: {}", + target, + summary.join(", ") + )); + } + } + + reply + } +} + +pub struct TwoThirdsAverage { + lobby: HashSet, + capacity: u8, + matches: HashMap, + players: HashMap, + channel: mpsc::UnboundedSender, + token: CancellationToken, + pending_reminders: HashSet, + next_match_id: usize, +} + +impl TwoThirdsAverage { + fn schedule_reminder(&mut self, match_id: usize) { + if !self.pending_reminders.insert(match_id) { + return; + } + let sender = self.channel.clone(); + let token = self.token.clone(); + tokio::spawn(async move { + tokio::select! { + _ = token.cancelled() => {} + _ = sleep(REMINDER_DELAY) => { + let _ = sender.send(GameEvent::Step(InternalCommand { + name: "twothirds".to_string(), + match_index: match_id, + command: "remind".to_string(), + })); + } + } + }); + } + + fn player_command(&mut self, command: &PlayerCommand) -> Reply { + let mut reply = Reply::new(); + let waiting = self.lobby.contains(&command.sender); + let playing = self.players.contains_key(&command.sender); + let words: Vec<_> = command.content.split_whitespace().collect(); + + match words[..] { + ["help"] => { + reply.quiet(); + if let Some(&match_id) = self.players.get(&command.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + let already_guessed = + matches!(current_match.guesses.get(&command.sender), Some(Some(_))); + reply.push(format!( + "@{} You're in a two-thirds game. Send 'guess ' to play; 'canceltwothirds' abandons the match.", + command.sender + )); + if already_guessed { + let waiting = current_match.waiting_players(); + if waiting.is_empty() { + reply.push("All guesses are in. Resolving shortly.".to_string()); + } else { + reply.push(format!( + "Waiting for guesses from: {}.", + waiting.join(", ") + )); + } + } else { + reply.push("You still need to submit your guess.".to_string()); + } + } else { + reply.push("Your previous two-thirds game has already ended.".to_string()); + self.players.remove(&command.sender); + } + } else if waiting { + reply.push(format!( + "@{} You're queued for a two-thirds game with {} player(s). We'll start when three players are ready.", + command.sender, + self.lobby.len() + )); + } else { + reply.push( + "Send 'twothirds' to join the lobby. During the match, use 'guess ' and 'canceltwothirds' to withdraw.".to_string(), + ); + } + } + ["twothirds"] => { + if waiting { + reply.push(format!( + "@{} You're already waiting for a two-thirds game.", + command.sender + )); + } else if playing { + reply.push(format!( + "@{} You're already playing a two-thirds game.", + command.sender + )); + } else if self.capacity > 1 { + reply.push("You are now waiting for a two-thirds game.".to_string()); + self.lobby.insert(command.sender.clone()); + self.capacity -= 1; + } else { + self.lobby.insert(command.sender.clone()); + let players = self.lobby.clone(); + self.lobby.clear(); + self.capacity = 3; + let match_id = self.next_match_id; + self.next_match_id += 1; + let new_match = Match::new(&players); + for player in players.iter() { + self.players.insert(player.clone(), match_id); + } + self.matches.insert(match_id, new_match); + reply.quiet(); + for player in players { + reply.push(format!( + "@{} Two-thirds game has started. Send 'guess ' to submit your value.", + player + )); + } + self.schedule_reminder(match_id); + } + } + ["guess", value] => { + if !playing { + reply.push(format!("@{} You must join a game first!", command.sender)); + } else if let Some(&match_id) = self.players.get(&command.sender) { + match self.matches.get_mut(&match_id) { + Some(current_match) => { + if matches!(current_match.guesses.get(&command.sender), Some(Some(_))) { + reply.push("You have already submitted a guess.".to_string()); + } else if let Ok(parsed) = value.parse::() { + current_match + .guesses + .insert(command.sender.clone(), Some(parsed)); + if current_match.is_ready() { + let participants: Vec = + current_match.guesses.keys().cloned().collect(); + let resolution = current_match.resolve(); + self.matches.remove(&match_id); + self.pending_reminders.remove(&match_id); + for participant in participants { + self.players.remove(&participant); + } + reply = resolution; + } else { + reply.push( + "Guess received. Waiting for other players.".to_string(), + ); + self.pending_reminders.remove(&match_id); + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } else { + reply.push( + "Please provide a valid number (e.g., 42 or 42.5).".to_string(), + ); + } + } + None => { + reply.push("Your match has already ended.".to_string()); + self.players.remove(&command.sender); + } + } + } + } + ["canceltwothirds"] => { + if waiting { + self.lobby.remove(&command.sender); + self.capacity = (self.capacity + 1).min(3); + reply.push("You are no longer waiting for a two-thirds game.".to_string()); + } else if let Some(&match_id) = self.players.get(&command.sender) { + if let Some(current_match) = self.matches.remove(&match_id) { + self.pending_reminders.remove(&match_id); + let others: Vec = current_match + .guesses + .keys() + .filter(|p| *p != &command.sender) + .cloned() + .collect(); + for participant in current_match.guesses.keys() { + self.players.remove(participant); + } + reply.push(format!( + "@{} cancelled the two-thirds game. The game has ended.", + command.sender + )); + if !others.is_empty() { + reply.quiet(); + for participant in others { + reply.push(format!( + "@{} Your two-thirds game was cancelled.", + participant + )); + } + } + } else { + self.players.remove(&command.sender); + reply.push("Your match has already ended.".to_string()); + } + } else { + reply.push(format!( + "@{} You are not currently in a two-thirds game.", + command.sender + )); + } + } + _ => {} + } + + reply + } + + fn internal_command(&mut self, command: &InternalCommand) -> Reply { + if command.name != "twothirds" { + return Reply::new(); + } + self.pending_reminders.remove(&command.match_index); + let waiting_players = self + .matches + .get(&command.match_index) + .map(|current| current.waiting_players()) + .unwrap_or_default(); + + if waiting_players.is_empty() { + return Reply::new(); + } + + let mut reply = Reply::new(); + reply.quiet(); + for player in waiting_players { + reply.push(format!( + "@{} Reminder: send 'guess ' for the two-thirds game.", + player + )); + } + self.schedule_reminder(command.match_index); + reply + } +} + +impl Game for TwoThirdsAverage { + fn new(c: mpsc::UnboundedSender, token: CancellationToken) -> Self { + TwoThirdsAverage { + lobby: HashSet::new(), + capacity: 3, + matches: HashMap::new(), + players: HashMap::new(), + channel: c, + token, + pending_reminders: HashSet::new(), + next_match_id: 0, + } + } + + fn next(&mut self, command: &Command) -> Reply { + match command { + Command::PlayerCommand(player) => self.player_command(player), + Command::InternalCommand(internal) => self.internal_command(internal), + } + } + + fn name(&self) -> &'static str { + "twothirds" + } + + fn save_state(&self) -> Option { + let lobby: Vec = self.lobby.iter().cloned().collect(); + let matches: Vec = self + .matches + .iter() + .map(|(id, m)| TwoThirdsMatchEntry { + id: *id, + state: m.clone(), + }) + .collect(); + let state = TwoThirdsState { + lobby, + capacity: self.capacity, + matches, + next_match_id: self.next_match_id, + }; + Value::try_from(state).ok() + } + + fn load_state(&mut self, value: &Value) -> Result<(), String> { + let state: TwoThirdsState = value.clone().try_into().map_err(|e| e.to_string())?; + self.lobby = state.lobby.into_iter().collect(); + self.capacity = state.capacity; + self.matches = state + .matches + .into_iter() + .map(|entry| (entry.id, entry.state)) + .collect(); + self.players.clear(); + for (match_id, m) in &self.matches { + for player in m.guesses.keys() { + self.players.insert(player.clone(), *match_id); + } + } + self.next_match_id = state.next_match_id; + self.pending_reminders.clear(); + for match_id in self.matches.keys().copied().collect::>() { + if let Some(current_match) = self.matches.get(&match_id) { + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct TwoThirdsMatchEntry { + id: usize, + state: Match, +} + +#[derive(Serialize, Deserialize)] +struct TwoThirdsState { + lobby: Vec, + capacity: u8, + matches: Vec, + next_match_id: usize, +} + +#[cfg(test)] +mod test { + use super::TwoThirdsAverage; + use crate::game::{Command, Game, InternalCommand, PlayerCommand}; + use crate::CancellationToken; + use tokio::sync::mpsc; + + fn command(game: &mut dyn Game, sender: &str, content: &str) { + let command = PlayerCommand { + sender: sender.to_string(), + content: content.to_string(), + }; + let _ = game.next(&Command::PlayerCommand(&command)); + } + + #[tokio::test] + async fn test_two_thirds_resolution() { + let mut game = TwoThirdsAverage::new(mpsc::unbounded_channel().0, CancellationToken::new()); + command(&mut game, "alice", "twothirds"); + command(&mut game, "bob", "twothirds"); + command(&mut game, "carol", "twothirds"); + command(&mut game, "alice", "guess 30"); + command(&mut game, "bob", "guess 40"); + let reply = game.next(&Command::PlayerCommand(&PlayerCommand { + sender: "carol".to_string(), + content: "guess 60".to_string(), + })); + assert!(reply.0.iter().any(|msg| msg.contains("two-thirds game"))); + } + + #[tokio::test] + async fn test_two_thirds_reminder() { + let (sender, _) = mpsc::unbounded_channel(); + let token = CancellationToken::new(); + let mut game = TwoThirdsAverage::new(sender, token); + command(&mut game, "alice", "twothirds"); + command(&mut game, "bob", "twothirds"); + command(&mut game, "carol", "twothirds"); + command(&mut game, "alice", "guess 30"); + let reminder = InternalCommand { + name: "twothirds".to_string(), + match_index: 0, + command: "remind".to_string(), + }; + let reply = game.next(&Command::InternalCommand(reminder)); + assert!(reply.0.iter().any(|msg| msg.contains("Reminder"))); + } + + #[tokio::test] + async fn test_help_during_two_thirds_game() { + let mut game = TwoThirdsAverage::new(mpsc::unbounded_channel().0, CancellationToken::new()); + command(&mut game, "alice", "twothirds"); + command(&mut game, "bob", "twothirds"); + command(&mut game, "carol", "twothirds"); + let reply = game.next(&Command::PlayerCommand(&PlayerCommand { + sender: "alice".to_string(), + content: "help".to_string(), + })); + assert!(reply.0.iter().any(|msg| msg.contains("guess "))); + } + + #[tokio::test] + async fn test_parallel_two_thirds_matches() { + let mut game = TwoThirdsAverage::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let players = ["alice", "bob", "carol", "dave", "eve", "frank"]; + + for player in players.iter() { + let command = PlayerCommand { + sender: player.to_string(), + content: "twothirds".to_string(), + }; + let _ = game.next(&Command::PlayerCommand(&command)); + } + + assert_eq!(game.matches.len(), 2); + assert_eq!(game.players.len(), 6); + let first = game.players.get("alice").cloned(); + let second = game.players.get("dave").cloned(); + assert!(first.is_some() && second.is_some() && first != second); + } +} ADDED src/unique.rs Index: src/unique.rs ================================================================== --- /dev/null +++ src/unique.rs @@ -0,0 +1,505 @@ +use crate::game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply}; +use crate::CancellationToken; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::sleep; +use toml::Value; + +const REMINDER_DELAY: Duration = Duration::from_secs(300); + +#[derive(Clone, Serialize, Deserialize)] +struct Match { + bids: HashMap>, +} + +impl Match { + fn new(players: &HashSet) -> Self { + let bids = players.iter().map(|p| (p.clone(), None)).collect(); + Match { bids } + } + + fn waiting_players(&self) -> Vec { + self.bids + .iter() + .filter_map(|(player, bid)| bid.is_none().then(|| player.clone())) + .collect() + } + + fn is_ready(&self) -> bool { + self.bids.values().all(|bid| bid.is_some()) + } + + fn resolve(&self) -> Reply { + let mut reply = Reply::new(); + let mut counts: HashMap = HashMap::new(); + for bid in self.bids.values().flatten() { + *counts.entry(*bid).or_insert(0) += 1; + } + + let mut winner: Option<(&str, u32)> = None; + for (player, bid) in &self.bids { + if let Some(bid) = bid { + if counts.get(bid) == Some(&1) { + match winner { + Some((_, current)) if *bid >= current => {} + _ => winner = Some((player.as_str(), *bid)), + } + } + } + } + + let summary: Vec = self + .bids + .iter() + .map(|(player, bid)| match bid { + Some(value) => format!("@{} → {}", player, value), + None => format!("@{} → (no bid)", player), + }) + .collect(); + + match winner { + Some((player, bid)) => { + reply.push(format!( + "@{} wins the unique lowest bid with {}. Results: {}", + player, + bid, + summary.join(", ") + )); + } + None => { + reply.push(format!( + "No unique lowest bid. Results: {}", + summary.join(", ") + )); + } + } + + reply + } +} + +pub struct UniqueLowestBid { + lobby: HashSet, + capacity: u8, + matches: HashMap, + players: HashMap, + channel: mpsc::UnboundedSender, + token: CancellationToken, + pending_reminders: HashSet, + next_match_id: usize, +} + +impl UniqueLowestBid { + fn schedule_reminder(&mut self, match_id: usize) { + if !self.pending_reminders.insert(match_id) { + return; + } + let sender = self.channel.clone(); + let token = self.token.clone(); + tokio::spawn(async move { + tokio::select! { + _ = token.cancelled() => {} + _ = sleep(REMINDER_DELAY) => { + let _ = sender.send(GameEvent::Step(InternalCommand { + name: "unique".to_string(), + match_index: match_id, + command: "remind".to_string(), + })); + } + } + }); + } + + fn player_command(&mut self, command: &PlayerCommand) -> Reply { + let mut reply = Reply::new(); + let waiting = self.lobby.contains(&command.sender); + let playing = self.players.contains_key(&command.sender); + let words: Vec<_> = command.content.split_whitespace().collect(); + + match words[..] { + ["help"] => { + reply.quiet(); + if let Some(&match_id) = self.players.get(&command.sender) { + if let Some(current_match) = self.matches.get(&match_id) { + let already_bid = + matches!(current_match.bids.get(&command.sender), Some(Some(_))); + reply.push(format!( + "@{} You're in a unique lowest bid match. Send 'bid ' (non-negative integer). Once everyone bids, lowest unique number wins.", + command.sender + )); + if already_bid { + let waiting = current_match.waiting_players(); + if waiting.is_empty() { + reply.push("All bids received. Resolving shortly.".to_string()); + } else { + reply.push(format!( + "Waiting for bids from: {}.", + waiting.join(", ") + )); + } + } else { + reply.push("You still need to submit your bid.".to_string()); + } + } else { + reply.push( + "Your previous unique lowest bid match has already ended.".to_string(), + ); + self.players.remove(&command.sender); + } + } else if waiting { + reply.push(format!( + "@{} You're queued for a unique lowest bid game with {} player(s). We'll start when three players are ready.", + command.sender, + self.lobby.len() + )); + } else { + reply.push( + "Send 'ulb' to join the lobby. During the match, use 'bid ' and 'cancelulb' to withdraw.".to_string(), + ); + } + } + ["ulb"] => { + if waiting { + reply.push(format!( + "@{} You're already waiting for a unique lowest bid game.", + command.sender + )); + } else if playing { + reply.push(format!( + "@{} You're already playing a unique lowest bid game.", + command.sender + )); + } else if self.capacity > 1 { + reply.push("You are now waiting for a unique lowest bid game.".to_string()); + self.lobby.insert(command.sender.clone()); + self.capacity -= 1; + } else { + self.lobby.insert(command.sender.clone()); + let players = self.lobby.clone(); + self.lobby.clear(); + self.capacity = 3; + let match_id = self.next_match_id; + self.next_match_id += 1; + let new_match = Match::new(&players); + for player in players.iter() { + self.players.insert(player.clone(), match_id); + } + self.matches.insert(match_id, new_match); + reply.quiet(); + for player in players { + reply.push(format!( + "@{} Unique lowest bid has started. Send 'bid ' to participate.", + player + )); + } + self.schedule_reminder(match_id); + } + } + ["bid", value] => { + if !playing { + reply.push(format!("@{} You must join a game first!", command.sender)); + } else if let Some(&match_id) = self.players.get(&command.sender) { + match self.matches.get_mut(&match_id) { + Some(current_match) => { + if matches!(current_match.bids.get(&command.sender), Some(Some(_))) { + reply.push("You have already submitted a bid.".to_string()); + } else if let Ok(parsed) = value.parse::() { + current_match + .bids + .insert(command.sender.clone(), Some(parsed)); + if current_match.is_ready() { + let participants: Vec = + current_match.bids.keys().cloned().collect(); + let resolution = current_match.resolve(); + self.matches.remove(&match_id); + self.pending_reminders.remove(&match_id); + for participant in participants { + self.players.remove(&participant); + } + reply = resolution; + } else { + reply.push( + "Bid received. Waiting for other players.".to_string(), + ); + self.pending_reminders.remove(&match_id); + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } else { + reply + .push("Please provide a non-negative integer bid.".to_string()); + } + } + None => { + reply.push("Your match has already ended.".to_string()); + self.players.remove(&command.sender); + } + } + } + } + ["cancelulb"] => { + if waiting { + self.lobby.remove(&command.sender); + self.capacity = (self.capacity + 1).min(3); + reply.push("You are no longer waiting for unique lowest bid.".to_string()); + } else if let Some(&match_id) = self.players.get(&command.sender) { + if let Some(current_match) = self.matches.remove(&match_id) { + self.pending_reminders.remove(&match_id); + let others: Vec = current_match + .bids + .keys() + .filter(|p| *p != &command.sender) + .cloned() + .collect(); + for participant in current_match.bids.keys() { + self.players.remove(participant); + } + reply.push(format!( + "@{} cancelled the unique lowest bid match. The game has ended.", + command.sender + )); + if !others.is_empty() { + reply.quiet(); + for participant in others { + reply.push(format!( + "@{} Your unique lowest bid match was cancelled.", + participant + )); + } + } + } else { + self.players.remove(&command.sender); + reply.push("Your match has already ended.".to_string()); + } + } else { + reply.push(format!( + "@{} You are not currently in a unique lowest bid game.", + command.sender + )); + } + } + _ => {} + } + + reply + } + + fn internal_command(&mut self, command: &InternalCommand) -> Reply { + if command.name != "unique" { + return Reply::new(); + } + self.pending_reminders.remove(&command.match_index); + let waiting_players = self + .matches + .get(&command.match_index) + .map(|current| current.waiting_players()) + .unwrap_or_default(); + + if waiting_players.is_empty() { + return Reply::new(); + } + + let mut reply = Reply::new(); + reply.quiet(); + for player in waiting_players { + reply.push(format!( + "@{} Reminder: send 'bid ' for the unique lowest bid game.", + player + )); + } + self.schedule_reminder(command.match_index); + reply + } +} + +impl Game for UniqueLowestBid { + fn new(c: mpsc::UnboundedSender, token: CancellationToken) -> Self { + UniqueLowestBid { + lobby: HashSet::new(), + capacity: 3, + matches: HashMap::new(), + players: HashMap::new(), + channel: c, + token, + pending_reminders: HashSet::new(), + next_match_id: 0, + } + } + + fn next(&mut self, command: &Command) -> Reply { + match command { + Command::PlayerCommand(player) => self.player_command(player), + Command::InternalCommand(internal) => self.internal_command(internal), + } + } + + fn name(&self) -> &'static str { + "unique" + } + + fn save_state(&self) -> Option { + let lobby: Vec = self.lobby.iter().cloned().collect(); + let matches: Vec = self + .matches + .iter() + .map(|(id, m)| UniqueMatchEntry { + id: *id, + state: m.clone(), + }) + .collect(); + let state = UniqueState { + lobby, + capacity: self.capacity, + matches, + next_match_id: self.next_match_id, + }; + Value::try_from(state).ok() + } + + fn load_state(&mut self, value: &Value) -> Result<(), String> { + let state: UniqueState = value.clone().try_into().map_err(|e| e.to_string())?; + self.lobby = state.lobby.into_iter().collect(); + self.capacity = state.capacity; + self.matches = state + .matches + .into_iter() + .map(|entry| (entry.id, entry.state)) + .collect(); + self.players.clear(); + for (match_id, m) in &self.matches { + for player in m.bids.keys() { + self.players.insert(player.clone(), *match_id); + } + } + self.next_match_id = state.next_match_id; + self.pending_reminders.clear(); + for match_id in self.matches.keys().copied().collect::>() { + if let Some(current_match) = self.matches.get(&match_id) { + if !current_match.waiting_players().is_empty() { + self.schedule_reminder(match_id); + } + } + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct UniqueMatchEntry { + id: usize, + state: Match, +} + +#[derive(Serialize, Deserialize)] +struct UniqueState { + lobby: Vec, + capacity: u8, + matches: Vec, + next_match_id: usize, +} + +#[cfg(test)] +mod test { + use super::UniqueLowestBid; + use crate::game::{Command, Game, InternalCommand, PlayerCommand}; + use crate::CancellationToken; + use tokio::sync::mpsc; + + fn command(game: &mut dyn Game, sender: &str, content: &str) { + let command = PlayerCommand { + sender: sender.to_string(), + content: content.to_string(), + }; + let _ = game.next(&Command::PlayerCommand(&command)); + } + + #[tokio::test] + async fn test_unique_lowest_bid_resolution() { + let mut game = UniqueLowestBid::new(mpsc::unbounded_channel().0, CancellationToken::new()); + command(&mut game, "alice", "ulb"); + command(&mut game, "bob", "ulb"); + command(&mut game, "carol", "ulb"); + command(&mut game, "alice", "bid 3"); + command(&mut game, "bob", "bid 3"); + let reply = game.next(&Command::PlayerCommand(&PlayerCommand { + sender: "carol".to_string(), + content: "bid 3".to_string(), + })); + assert!(reply + .0 + .iter() + .any(|msg| msg.contains("No unique lowest bid"))); + } + + #[tokio::test] + async fn test_unique_lowest_bid_winner() { + let mut game = UniqueLowestBid::new(mpsc::unbounded_channel().0, CancellationToken::new()); + command(&mut game, "alice", "ulb"); + command(&mut game, "bob", "ulb"); + command(&mut game, "carol", "ulb"); + command(&mut game, "alice", "bid 5"); + command(&mut game, "bob", "bid 7"); + let reply = game.next(&Command::PlayerCommand(&PlayerCommand { + sender: "carol".to_string(), + content: "bid 4".to_string(), + })); + assert!(reply + .0 + .iter() + .any(|msg| msg.contains("wins the unique lowest bid"))); + } + + #[tokio::test] + async fn test_reminder_internal_command() { + let (sender, _) = mpsc::unbounded_channel(); + let token = CancellationToken::new(); + let mut game = UniqueLowestBid::new(sender, token); + command(&mut game, "alice", "ulb"); + command(&mut game, "bob", "ulb"); + command(&mut game, "carol", "ulb"); + command(&mut game, "alice", "bid 5"); + let reminder = InternalCommand { + name: "unique".to_string(), + match_index: 0, + command: "remind".to_string(), + }; + let reply = game.next(&Command::InternalCommand(reminder)); + assert!(reply.0.iter().any(|msg| msg.contains("Reminder"))); + } + + #[tokio::test] + async fn test_help_during_unique_lowest_bid() { + let mut game = UniqueLowestBid::new(mpsc::unbounded_channel().0, CancellationToken::new()); + command(&mut game, "alice", "ulb"); + command(&mut game, "bob", "ulb"); + command(&mut game, "carol", "ulb"); + let reply = game.next(&Command::PlayerCommand(&PlayerCommand { + sender: "alice".to_string(), + content: "help".to_string(), + })); + assert!(reply.0.iter().any(|msg| msg.contains("bid "))); + } + + #[tokio::test] + async fn test_parallel_unique_matches() { + let mut game = UniqueLowestBid::new(mpsc::unbounded_channel().0, CancellationToken::new()); + let players = ["alice", "bob", "carol", "dave", "eve", "frank"]; + + for player in players.iter() { + let command = PlayerCommand { + sender: player.to_string(), + content: "ulb".to_string(), + }; + let _ = game.next(&Command::PlayerCommand(&command)); + } + + assert_eq!(game.matches.len(), 2); + assert_eq!(game.players.len(), 6); + let first = game.players.get("alice").cloned(); + let second = game.players.get("dave").cloned(); + assert!(first.is_some() && second.is_some() && first != second); + } +}