Pravda

Changes On Branch spoof
Login

Changes On Branch spoof

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch spoof Excluding Merge-Ins

This is equivalent to a diff from 51d2b8d26e to 26a358faf5

2025-09-30
05:05
Improve twothirds command routing check-in: f59ed8ae2e user: david tags: v2
2025-09-29
15:55
Adding catalog file check-in: 26a358faf5 user: david tags: spoof
11:48
Streamline catalog and add soviet integration tests check-in: c9fae67e81 user: david tags: spoof
2025-03-28
15:38
Not sure what I'm committing. check-in: d41794db3f user: david tags: spoof
2024-03-27
08:03
Fixed some warnings and comments. Leaf check-in: 51d2b8d26e user: david tags: trunk
2024-03-26
09:06
Adapting the structure to deal with more complex games and merging Spoof, but not ready yet. check-in: 223ad50bdb user: david tags: trunk

Added AGENTS.md.


































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 "<imperative summary>"`.
- 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.

Changes to Cargo.toml.

18
19
20
21
22
23
24
25

26





27
28
29
30
31
32
33
18
19
20
21
22
23
24

25
26
27
28
29
30
31
32
33
34
35
36
37
38







-
+

+
+
+
+
+







futures-util = { default-features = false, version = "0.3" }
nanohtml2text = "0.1.4"
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"
lto = true
codegen-units = 1

Changes to README.md.

1
2
3



4
5

6
7







8
9






10
11

12
13
14

15
16

17







18
19

20
21
22

23





24
25

26
27






28

29

30
31
32
33
34
35
36
37





38
39




40
41


42
43

44
45




46
47

48
49
50
51



52
53

54



55

56
57
58
59




1
2

3
4
5
6

7
8

9
10
11
12
13
14
15
16

17
18
19
20
21
22
23

24
25


26


27

28
29
30
31
32
33
34
35

36
37


38

39
40
41
42
43
44

45
46

47
48
49
50
51
52
53
54

55








56
57
58
59
60


61
62
63
64
65

66
67
68

69
70

71
72
73
74
75

76
77



78
79
80
81

82
83
84
85
86

87




88
89
90
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.
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.

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.
Out of the box the bot supports:

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.
- **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.

Bear in mind you must have a valid account on the instance before you attempt to authorise the bot.
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.

### First run.
### Getting Started

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.

1. Install Rust and clone this repository.
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.

2. Run `cargo run --bin game_cli` to exercise the games locally via stdin.
On subsequent runs, if this file is encountered, the information will be read from it and the connection will be realised without human intervention.
   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.

### Limitations.
### Development Workflow

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.

- Run `cargo fmt` and `cargo clippy --all-targets` to keep formatting and
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.
  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.

### Future ambitions.
### Security & Operational Notes

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:
- 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
* [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.
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.
* Guess 2/3 the average.
* Predict the judgement.
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.

Some of these games need more interesting names.
Unit tests live alongside their games. Existing tests illustrate how to drive
command sequences without Mastodon.

It would be nice to run a nomic on the fedi, but that is a lot more than a bot can do by itself.
### Planned Work

## Bug reports, feature requests, code contributions and other feedback.
- 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.

If you want to tell me something about the crate, the best ways are:
### Support & Feedback

* 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.
- Mastodon: [@modulux@node.isonomia.net](https://node.isonomia.net/@modulux)
- Fossil repo: <https://modulus.isonomia.net/pravda.cgi>
- Email: modulus at isonomia dot net

## Change log.
### Changelog

* Unreleased
  * Added Unique Lowest Bid, Two Thirds Average, and Soviet Mafia games.
  * Hardened outbound post handling and added a shared game manager.
* V 0.2.0: Spoof is implemented.
* V0.2.0 Spoof
* 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.
* 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.



























































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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<dyn Error>> {
    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<Box<dyn Game>> = catalog::default_games(&sender, &token);

    println!("Pravda CLI test harness");
    println!("Enter '<acct>: <command>'. 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 '<game> 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 '<acct>: <command>'.");
                    }
                } else {
                    break;
                }
            }
        }
    }

    println!("Goodbye");
    Ok(())
}

fn handle_event(event: GameEvent, games: &mut [Box<dyn Game>]) {
    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.












































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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<GameEvent>,
    token: &CancellationToken,
) -> Vec<Box<dyn Game>> {
    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<dyn crate::game::Game>],
        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<Box<dyn super::Game>> {
        let (sender, _receiver) = mpsc::unbounded_channel::<GameEvent>();
        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")));
    }
}

Changes to src/dice.rs.

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18
19











-
+







use crate::game::{Command, Game, GameEvent, Reply};
use crate::{Lazy, Regex};
use rand::Rng;

pub struct Dice;

impl Game for Dice {
    fn new(
        _: tokio::sync::mpsc::UnboundedSender<GameEvent>,
        _: tokio_util::sync::CancellationToken,
    ) -> Self {
    Dice
        Dice
    }
    fn next(&mut self, m: &Command) -> Reply {
        let mut r = Reply::new();
        static DICE: Lazy<Regex> = Lazy::new(|| {
            Regex::new(r"^(?:dice|dado)(?: (\d+)(?:d(\d+))?)?$")
                .expect("Problem building dice regexp.")
        });
75
76
77
78
79
80
81
82





75
76
77
78
79
80
81

82
83
84
85
86







-
+
+
+
+
+
                    }
                }
                _ => {}
            }
        }
        r
    }
}

    fn name(&self) -> &'static str {
        "dice"
    }
}

Changes to src/game.rs.

1
2
3
4
5
6






7
8
9
10
11
12
13
14
15

16










17
18
19
20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

63
64
65


66
1





2
3
4
5
6
7
8
9
10
11
12
13
14
15

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

75
76
77
78
79
80
81

-
-
-
-
-
+
+
+
+
+
+








-
+

+
+
+
+
+
+
+
+
+
+











+
















+


















-
+



+
+

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<GameEvent>,
        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<Value> {
        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)]
pub struct Reply(pub Vec<String>, pub Visibility);
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) {
        self.1 = Visibility::Direct;
    }
}

/// A command from a player: sender and content.
pub struct PlayerCommand {
    pub sender: String,
    pub content: String,
}

/// An internal command: contains the match it is for, and a string.
#[derive(Clone)]
pub struct InternalCommand {
    pub name: String,
    pub match_index: usize,
    pub command: String,
}

/// A game command.
pub enum Command<'a> {
    /// It may be a PlayerCommand.
    PlayerCommand(&'a PlayerCommand),
    /// Or an internal command to a specific match.
    InternalCommand(InternalCommand),
}

/// An event.
pub enum GameEvent {
    /// A response to send.
    Reply(Reply),
    /// Or an Internal Command..
    /// 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.














1
2
3
4
5
6
7
8
9
10
11
12
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;

Changes to src/main.rs.

1
2


3
4
5

6
7
8
9
10
11

12

13
14
15
16
17

18

19
20
21
22
23




24
25
26
27
28
29
30
31
32


33
34
35
36
37
38

39
40
41
42
43
44
45
46
47
48

49
50

51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66





67
68
69
70
71
72
















73
74
75
76
77
78
79

80
81
82
83
84
85
86







87
88
89
90
91
92
93



94
95
96

97
98
99




100
101
102
103
104
105





106
107
108
109
110
111



112
113
114
115
116
117
118
119
120
121
122
123
124
125









126
127
128
129
130
131
132
133
134



135
136
137


138
139
140
141
142
143

144
145
146
147
148
149
150
151
152
153
154
155

156
157

158
159
160
161
162







163
164
165
166
167

168
169
170

171
172

173
174
175
176
177
178
179
180






181
182
183

184
185

186
187
188
189

190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208

209
210
211
212
213
214
215
216
217












































































































































































































































































































1
2
3
4

5
6
7
8


9
10

11
12

13
14
15
16
17
18
19




20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

40
41
42
43
44
45
46
47
48
49

50


51



52
53
54
55
56
57
58
59
60




61
62
63
64
65






66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

88
89
90
91
92
93
94

95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112


113



114
115
116
117
118
119
120
121


122
123
124
125
126
127





128
129
130














131
132
133
134
135
136
137
138
139
140
141
142
143
144
145



146
147
148
149


150
151
152





153












154


155





156
157
158
159
160
161
162





163



164


165








166
167
168
169
170
171



172
173

174




175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193

194
195
196
197
198
199
200

201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
-
-
+
+


-
+



-
-

+
-
+

-



+

+

-
-
-
-
+
+
+
+









+
+





-
+









-
+
-
-
+
-
-
-









-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+






-
+






-
+
+
+
+
+
+
+







+
+
+

-
-
+
-
-
-
+
+
+
+




-
-
+
+
+
+
+

-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+






-
-
-
+
+
+

-
-
+
+

-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
-
-
-
+
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+

-
+
-
-
-
-
+


















-
+






-


+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 serde::{Deserialize, Serialize};
use std::cmp::min;
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<Regex> =
        Lazy::new(|| Regex::new(r"<a.*?</a>").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) {
fn print_status(sender: &str, content: &str) {
    println!(
        "Received from {}: {}",
    println!("Received from {}: {}", sender, content);
        s.account.acct,
        clean(&s.content).await
    );
}

#[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 game_manager = GameManager::new(&cs, &token);
    if let Some(state) = load_persisted_state() {
        game_manager.load_states(&state.games);
    }

    let mut g: Vec<Box<dyn Game>> = 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 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
                    let Some(status) = n.status else {
                        .expect("Event is a notification but does not have a status.");
                    let content = clean(&s.content).await;
                    if sender == ADMIN && s.content.contains(QUIT) {
                        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.
                    game_manager.dispatch_player(&posting_client, &comm).await;
                }
                GameEvent::Reply(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;
                        }
                    }
                    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;
            }
        };

        match stream_notifications_loop(&mastodon, &cs, &token2).await {
        select! {
        _ = token2.cancelled() =>
            StreamOutcome::Cancelled => {
        {
        println!("Shutting down...");
        break 'stream;
        }

                println!("Shutting down...");
                break;
            }
            StreamOutcome::Finished => {
                if backoff > 0 {
                    backoff -= 1;
                }
        _ =
        stream.try_for_each(|(event, _)| async {
        if let Event::Notification(n) = event {
        cs.send(GameEvent::Notification(n)).expect("Problem sending event through channel.");
        }
            }
        Ok(())
        })
        => {
            StreamOutcome::Error(err) => {
        if bl < MAX_BL { bl += 1; }
        println!("Streaming terminated. Backing off level now {}", bl);
                eprintln!("Streaming error: {}", err);
        }
        Some(()) = async {
        loop {
        sleep(Duration::from_secs(30)).await;
        if bl > 0 {
            bl -= 1;
            println!("Backing off level now {}.", bl);
        }
                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);
                }
        // Return value for the type checker.
        if false { return None }
        }
            }
        }
        =>

        {}
        };
        println!("Streaming terminated. Loop: {}. Backing off: {}", l, bl);
        l += 1;
        attempt = attempt.saturating_add(1);
    }

    Ok(())
}

async fn register() -> Result<Mastodon> {
    let client = ClientBuilder::new()
        .user_agent("Werewolf 0.1")
        .build()
        .expect("Problem building Reqwest client for registration.");
    let registration = Registration::new_with_client(INSTANCE, client)
        .client_name("Werewolf 0.1")
        .scopes(Scopes::read_all() | Scopes::write_all() | Scopes::follow())
        .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<String> {
    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<Box<dyn Game>>,
}

impl GameManager {
    fn new(channel: &mpsc::UnboundedSender<GameEvent>, token: &CancellationToken) -> Self {
        let games = catalog::default_games(channel, token);
        Self { games }
    }

    fn load_states(&mut self, states: &HashMap<String, Value>) {
        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<String, Value>,
}

/// Load persisted state from disk if present, logging any parse errors.
fn load_persisted_state() -> Option<PersistedState> {
    if !Path::new(STATE_FILE).exists() {
        return None;
    }
    match fs::read_to_string(STATE_FILE) {
        Ok(contents) => match toml::from_str::<PersistedState>(&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<GameEvent>,
    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<GameEvent>,
    cancel: &CancellationToken,
) -> std::result::Result<StreamOutcome, StreamError> {
    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<GameEvent>,
    cancel: &CancellationToken,
) -> std::result::Result<StreamOutcome, StreamError> {
    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<Url, StreamError> {
    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<String>,
    payload: Option<String>,
}

fn parse_ws_notification(text: &str) -> std::result::Result<Option<Notification>, 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))
}

Changes to src/rps.rs.

1

2
3
4
5

6
7
8

9
10
11
12
13
14
15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

+




+



+







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<String, Option<Play>>,
}

impl Match {
    /// Create a new match from the set of players in the lobby.
    fn new(h: &HashSet<String>) -> Self {
82
83
84
85
86
87
88
89
90


91
92


93
94
95
96
97
98
99
100
101
102
103
104

105

106
107
108
109
110
111

112
113
114
115

116
117
118
119
120
121
122
123
124
125

































126
127
128
129
130
131
132
133
134

135
136
137
138

139
140
141
142
143
144
145
146
147
148
149

150
151
152
153
154

155
156
157
158
159
160
161
162


163
164
165

166
167
168
169
170
171




172
173
174
175
176
177
178






179
180
181
182
183
184
185
186
187







188
189
190

191
192
193
194
195
196


197
198


199
200
201
202
203
204
205










206
207

208
209
210
211
212
213
214
215




216
217
218
219
220

221
222
223
224



225
226
227


228
229
230
231


232
233
234
235
236






237
238



239
240

241
242
243
244
245
246











247
248
249




250
251
252
253
254
255
256







257
258

259
260


261
262
263
264

265
266
267

268
269
270
271




272
273
274
275









276

277



278
279
280

281



282



























283
284
285
286
287
288


































289
290
291
292

293
294
295
296
297
298
299
85
86
87
88
89
90
91


92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

109
110
111
112
113
114
115
116

117
118
119
120

121
122
123
124



125
126


127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167

168
169
170
171

172
173




174


175


176
177
178



179

180

181




182
183

184

185






186
187
188
189
190






191
192
193
194
195
196
197
198







199
200
201
202
203
204
205

206

207
208
209
210
211

212
213
214


215
216
217
218





219
220
221
222
223
224
225
226
227
228


229
230

231

232



233
234
235
236
237

238


239




240
241
242



243
244




245
246
247
248



249
250
251
252
253
254


255
256
257

258
259






260
261
262
263
264
265
266
267
268
269
270



271
272
273
274
275
276





277
278
279
280
281
282
283


284


285
286




287
288


289
290
291
292
293
294
295
296
297
298



299
300
301
302
303
304
305
306
307
308
309

310
311
312
313

314
315

316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346






347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383

384
385
386
387
388
389
390
391







-
-
+
+


+
+











-
+

+





-
+



-
+



-
-
-


-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+








-
+



-
+

-
-
-
-

-
-

-
-
+


-
-
-
+
-

-

-
-
-
-
+
+
-

-
+
-
-
-
-
-
-
+
+
+
+

-
-
-
-
-
-
+
+
+
+
+
+


-
-
-
-
-
-
-
+
+
+
+
+
+
+
-

-
+




-

+
+
-
-
+
+


-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+

-

-

-
-
-
+
+
+
+

-

-
-
+
-
-
-
-
+
+
+
-
-
-
+
+
-
-
-
-
+
+


-
-
-
+
+
+
+
+
+
-
-
+
+
+
-

+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+


-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
-
-
+
+
-
-
-
-
+

-
-
+




+
+
+
+

-
-
-
+
+
+
+
+
+
+
+
+

+
-
+
+
+

-

+
-
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+



-
+








/// Data structure of the game.
pub struct Rps {
    /// A HashSet with the players waiting to play as account strings.
    lobby: HashSet<String>,
    /// capacity determines  how many people a match contains.
    capacity: u8,
    /// A vector of ongoing matches.
    matches: Vec<Match>,
    /// Map of ongoing matches keyed by stable identifier.
    matches: HashMap<usize, Match>,
    /// HashSet indicating for each player which match they are in.
    players: HashMap<String, usize>,
    /// Next match identifier to allocate.
    next_match_id: usize,
}

impl Game for Rps {
    /// Creation of a new and empty Rps game structure.
    fn new(
        _c: tokio::sync::mpsc::UnboundedSender<GameEvent>,
        _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));
                    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;
                }
                // If Someone's waiting, start the game.
                else {
                } 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();
                    let participants: Vec<String> = players.iter().cloned().collect();
                    self.lobby.clear();
                    // Reset capacity.
                    self.capacity = 2;
                    // Add the match to the matches vector.
                    let match_id = self.next_match_id;
                    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);
                    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);
                    }
                    // 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 {}
                    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*?",
                            i,
                            self.matches[n].opponent(i)
                        ));
                    }
                }
            }

                                participant,
                                current_match.opponent(participant)
                            ));
                        }
                    }
                }
            }
            // We're trying to join, but already in the lobby.
            (true, false, None, false, true) => {
                r.push(format!(
                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) => {
                if let Some(&match_id) = self.players.get(&m.sender) {
                    if let Some(current_match) = self.matches.get(&match_id) {
                r.push(format!(
                    "@{} You're already playing a game against {}
                        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
                        ));
                    }
                }
            // 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));
                reply.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.");
                if let Some(&match_id) = self.players.get(&m.sender) {
                // Get our opponent.
                let o = self.matches[*n].opponent(&m.sender);
                // Remove the match.
                self.matches.remove(*n);
                    if let Some(current_match) = self.matches.get(&match_id) {
                        let opponent = current_match.opponent(&m.sender);
                        self.matches.remove(&match_id);
                // Remove both players from the player list.
                self.players.remove(&m.sender);
                self.players.remove(&o);
                        self.players.remove(&m.sender);
                        self.players.remove(&opponent);
                // 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 @{}
                        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) => {
                }
            }
            (false, false, Some(choice), true, false) => {
                // Our name for later insertion.
                let name = m.sender.clone();
                let Some(&match_id) = self.players.get(&name) else {
                // 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() {
                    reply.push("Your match has already ended.".to_string());
                    return reply;
                };

                let mut resolution: Option<(Vec<String>, 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.")
                    // We can't play twice.
                    r.push(format!(
                        "@{} You already sent me your choice. You need to wait for {}
                        .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));
                            name,
                            current_match.opponent(&name)
                        ));
                        return reply;
                    }

                    current_match.state.insert(name.clone(), Some(choice));
                    // Check if the match is done.
                    if self.matches[*n].is_ready() {
                    if current_match.is_ready() {
                        // We're ready. Solve the game.
                        r = self.matches[*n].solve();
                        let players: Vec<String> = current_match.state.keys().cloned().collect();
                        let solve_reply = current_match.solve();
                        // Clean up.
                        self.matches.remove(*n);
                        self.players.remove(&m.sender);
                        self.players.remove(&o);
                        resolution = Some((players, solve_reply));
                    } else {
                        // Game isn't over, just send the message.
                        r.push(format!(
                        reply.push(format!(
                            "@{} Got your move. Let's see what your opponent does.",
                            m.sender
                        ));
                    }
                } else {
                    self.players.remove(&name);
                    reply.push("Your match has already ended.".to_string());
                    return reply;
                }
            }
            // And moves out of time.
            // When we're not in a game or waiting to play.

                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!(
                r.push(format!("@{} You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", m.sender));
                    "@{} 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) => {
                reply.push(format!(
                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));
                    "@{} 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<Value> {
        let lobby: Vec<String> = self.lobby.iter().cloned().collect();
        let matches: Vec<RpsMatchEntry> = 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()

            _ => {} // __
        }

        r
    }
    }

    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<String>,
    capacity: u8,
    matches: Vec<RpsMatchEntry>,
    next_match_id: usize,
}

/// Valid plays.
#[derive(PartialEq, Debug)]
#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
enum Play {
    Rock,
    Paper,
    Scissors,
}

impl Play {
483
484
485
486
487
488
489

















490
491
492
493
494
495
496
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







        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_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(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
553
554
555
556
557
558
559
560
























662
663
664
665
666
667
668

669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692







-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
            "@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_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.


































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
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<String>,
    frame: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
enum StalinOrder {
    Verify,
    Rehabilitate(String),
}

struct SovietMatch {
    lobby: HashSet<String>,
    players: HashMap<String, PlayerState>,
    stage: Stage,
    day: u32,
    night: u32,
    votes: HashMap<String, Option<String>>,
    saboteur_votes: HashMap<String, Option<String>>,
    commissar_target: Option<String>,
    chekist_action: Option<ChekistOrder>,
    militsioner_target: Option<String>,
    spy_actions: HashMap<String, SpyNight>,
    mastermind_target: Option<String>,
    stalin_action: Option<StalinOrder>,
    framed_players: HashSet<String>,
    channel: mpsc::UnboundedSender<GameEvent>,
    _token: CancellationToken,
    reminder_task: Option<JoinHandle<()>>,
}

impl SovietMatch {
    fn new(channel: mpsc::UnboundedSender<GameEvent>, 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<String> {
        let mut participants: HashSet<String> = 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<String> {
        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<String> = self.lobby.iter().cloned().collect();
        players.shuffle(&mut rng);
        let n = players.len();

        let mut roles: Vec<Role> = 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 <citizen>' to test loyalty. Be wary of false positives.".to_string(),
                Role::Chekist { .. } => "Chekist: 'shoot <target>' at night to eliminate enemies. Beware misfires and limited ammunition. 'unload' or 'reload' to manage your TT-33.".to_string(),
                Role::Militsioner => "Militsioner: 'guard <citizen>' 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 <citizen>' to learn loyalties (risking exposure) and 'frame <citizen>' to seed incriminating evidence.".to_string(),
                Role::TrotskyiteMastermind => "Trotskyite Mastermind: 'subvert <citizen>' nightly to recruit them. Failure may expose you.".to_string(),
                Role::Stalin { .. } => "Stalin: 'verify' once to reveal yourself as the Vozhd, or 'rehabilitate <citizen>' 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 <citizen>' 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 <citizen>' 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<Reply> {
        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<Option<String>, 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<String>, 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<String>, 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<String>) -> 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<String> {
        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 <citizen>' 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 <citizen>', '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<Reply> {
        // 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<Option<String>, 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<Option<String>>) -> 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<String> = 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<String> {
        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::<f64>() < 0.8 {
                hostile = true;
            }
        }
        if !hostile && rng.gen::<f64>() < 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<String> {
        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::<f64>() < 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::<f64>() < 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::<f64>() < 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::<f64>() < 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::<f64>() < 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<String>,
    players: HashMap<String, PlayerState>,
    stage: Stage,
    day: u32,
    night: u32,
    votes: HashMap<String, Option<String>>,
    saboteur_votes: HashMap<String, Option<String>>,
    commissar_target: Option<String>,
    chekist_action: Option<ChekistOrder>,
    militsioner_target: Option<String>,
    spy_actions: HashMap<String, SpyNight>,
    mastermind_target: Option<String>,
    stalin_action: Option<StalinOrder>,
    framed_players: Vec<String>,
}

pub struct SovietMafia {
    matches: HashMap<usize, SovietMatch>,
    player_matches: HashMap<String, usize>,
    channel: mpsc::UnboundedSender<GameEvent>,
    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<String> = 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<SovietMatchEntry>,
}

impl Game for SovietMafia {
    fn new(c: mpsc::UnboundedSender<GameEvent>, 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<Value> {
        let matches: Vec<SovietMatchEntry> = 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<usize> {
        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<String> = (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<String> = (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);
    }
}

Changes to src/spoof.rs.

1
2
3
4



5

6



7




8
9



10

11





12
13
14
15
16
17
18






19
20
21
22


















23
24
25
26
27
28










































29
30
31
32
33
34
35
36
37

38
39
40
41
42
43






44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59


















































































60
61

62
63
64
65
66
67
68
69
70

























71




72






73
74
75
76
77











78
79
80
81
82
83
84
85
86
87
88


















































89
90






91
92
93
94
95
96




































97
98
99
100
101
102


103
104
105
106

107
108
109





































110
111
112
113
114
115
116

117
118
119
120
121
122
123


124
125
126





127
128












































129

130
131
132
133
134
135














136
137
138
139
140
141
142
143
144


























145
146

147

148


149
150
151
152
153

154
155
156
157
158
159



160
161
162
163

164

165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182


183
184
185


186
187
188
189
190
191
192
193
194
195
196








197
198

199

200
201
202
203


204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227

228
229
230
231

232
233
234
235
236
237
238








239
240
241
242








243
244
245
246
247


248
249

250
251
252
253
254


255
256
257
258



259








260
261

262
263

264
265

266
267
268
269
270
271

272
273
274
275
276



277
278
279
280










281
282
283
284
285
286
287
288
289
290
291
292
293
294

295
296

297
298
299
300


301
302

303
304
305
306
307
308


309
310
311
312




313
314
315
316
317
318









319
320
321
322

323
324
325

326
327
328
329
330
331


332
333
334
335
336
337
338
339
340
341



342
343
344
345
346
347





348
349

350
351
352
353





354
355
356

357
358
359


360
361
362

363
364
365
366




367
368




1
2
3
4
5

6
7
8
9
10
11
12
13


14
15
16
17
18

19
20
21
22
23
24
25
26




27
28
29
30
31
32
33



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97



98

99


100

101




102
103
104
105
106
107
108
109
110













111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193

194









195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219

220
221
222
223
224
225
226
227
228
229
230





231
232
233
234
235
236
237
238
239
240
241











242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291


292
293
294
295
296
297






298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338

339
340
341
342

343
344



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385



386


387

388


389
390
391
392

393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443

444






445
446
447
448
449
450
451
452
453
454
455
456
457
458









459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487

488
489
490
491
492
493
494
495

496

497
498



499
500
501




502
503
504
505

















506
507



508
509











510
511
512
513
514
515
516
517


518

519
520
521


522
523
























524




525







526
527
528
529
530
531
532
533




534
535
536
537
538
539
540
541
542




543
544


545





546
547




548
549
550

551
552
553
554
555
556
557
558


559


560


561






562





563
564
565




566
567
568
569
570
571
572
573
574
575












576

577


578
579
580


581
582


583






584
585




586
587
588
589






590
591
592
593
594
595
596
597
598




599



600






601
602










603
604
605






606
607
608
609
610


611




612
613
614
615
616



617



618
619



620




621
622
623
624
625
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};
use std::time::Duration;
/// Spoof.
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 {
    /// Map of player account -> optional guess value.
    state: HashMap<String, Option<Play>>,
    state: HashMap<String, Option<u16>>,
    /// Coins each player decided to keep.
    coins: HashMap<String, u16>,
    /// Track used guesses to enforce uniqueness.
    guesses: HashSet<u16>,
}

impl Match {
    fn new(h: &HashSet<String>) -> Self {
        let mut n = HashMap::new();
        for key in h {
            n.insert(key.clone(), None);
    fn new(players: &HashSet<String>) -> 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: n }
    }

        Match {
            state,
            coins,
            guesses: HashSet::new(),
        }
    }

    fn participants(&self) -> Vec<String> {
        self.state.keys().cloned().collect()
    }

    fn waiting_players(&self) -> Vec<String> {
        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())
    }
}

/// Data structure of the game.

    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<String> = 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
    }
}

pub struct Spoof {
    /// name of the game.
    name: String,
    /// A HashSet with the players waiting to play as account strings.
    lobby: HashSet<String>,
    /// capacity determines  how many people can fit in the lobby.
    capacity: u8,
    /// A vector of ongoing matches.
    matches: Vec<Match>,
    matches: HashMap<usize, Match>,
    /// HashSet indicating for each player which match they are in.
    players: HashMap<String, usize>,
    /// 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<GameEvent>,
    /// Cancellation token.
    token: tokio_util::sync::CancellationToken,
    channel: mpsc::UnboundedSender<GameEvent>,
    token: CancellationToken,
    pending_reminders: HashSet<usize>,
    next_match_id: usize,
    pending_lobby_timeout: Option<u64>,
    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.
    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<Reply> {
        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 <n>' to choose your coins, then 'guess <total>' 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[..] {
            ["spoof"] => {
            ["help"] => {
                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
                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 <n>' to choose your coins, then 'guess <total>' 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 <n>' and 'guess <total>' once a match begins.".to_string(),
                    );
                }
            }
            ["spoof"] => {
                    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 {
                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 {
                        // 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);
                        }
                    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::<u16>().unwrap_or(0);
                        current_match.coins.insert(m.sender.clone(), coins);
                        reply.push(format!(
                            "You have kept {} coins. When you are ready, send 'guess <total>' to make your prediction.",
                            coins
                        ));
                    } else {
                        reply.push("Your match has already ended.".to_string());
                        self.players.remove(&m.sender);
                    }
                        // Send start command.
                        self.channel
                }
            }
            ["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) {
                            .send(GameEvent::Step(InternalCommand {
                                name: self.name.clone(),
                                match_index: n,
                                command: "start".to_string(),
                            }))
                            .expect("Problem sending event through channel.");
                    match self.matches.get_mut(&match_id) {
                        Some(current_match) => {
                            let guess_val = guess.parse::<u16>().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 {
        if m.name != "spoof" {
        let mut r = Reply::new();
        if m.name == self.name {}
        r
            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 <n>' and 'guess <total>' 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<GameEvent>,
    fn new(c: mpsc::UnboundedSender<GameEvent>, token: CancellationToken) -> Self {
        token: tokio_util::sync::CancellationToken,
    ) -> Self {
        Spoof {
            name: "spoof".to_string(),
            lobby: HashSet::new(),
            capacity: 10,
            matches: Vec::new(),
            capacity: MAX_PLAYERS as u8,
            matches: HashMap::new(),
            players: HashMap::new(),
            channel: c,
            token: token,
            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<Value> {
        let lobby: Vec<String> = self.lobby.iter().cloned().collect();
        let matches: Vec<SpoofMatchEntry> = 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);

            }
    /// 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),
        }
        self.next_match_id = state.next_match_id;
        self.pending_reminders.clear();
        for match_id in self.matches.keys().copied().collect::<Vec<_>>() {
            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();
        };
        r
    }
}

/// Play is a guess, or a failure to guess.
struct Play(Option<u16>);

/// Tests.
        }
        Ok(())
    }
}

#[derive(Serialize, Deserialize)]
struct SpoofMatchEntry {
    id: usize,
    state: Match,
}

#[derive(Serialize, Deserialize)]
struct SpoofState {
    lobby: Vec<String>,
    capacity: u8,
    matches: Vec<SpoofMatchEntry>,
    next_match_id: usize,
}

#[cfg(test)]
impl Spoof {
    fn pending_lobby_timeout_id(&self) -> Option<u64> {
        self.pending_lobby_timeout
    }
}

#[cfg(test)]
mod test {
    use super::{LOBBY_TIMEOUT, MAX_PLAYERS};
    use crate::game::{Command, Game, PlayerCommand, Reply};
    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));
        g.next(&Command::PlayerCommand(&p))
        r
    }

    #[test]
    fn test_new() {
        let g = Spoof::new(
    #[tokio::test]
    async fn test_new() {
        let g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new());
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        assert_eq!(g.capacity, 10);
        assert_eq!(g.capacity, MAX_PLAYERS as u8);
        assert_eq!(g.lobby.len(), 0);
        assert!(g.matches.is_empty());
    }

    #[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());
    }


    #[tokio::test]
    #[test]
    fn test_first_join_game() {
        let mut g = Spoof::new(
    async fn test_join_game() {
        let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::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,
        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
            "Capacity in lobby should be 1, is {}.",
            g.capacity
            .iter()
        );
            .any(|msg| msg.contains(&format!("after {} minutes", LOBBY_TIMEOUT.as_secs() / 60))));
    }

    #[test]
    fn test_join_game_twice() {
    #[tokio::test]
    async fn test_play_and_guess() {
        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(
        let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new());
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string());
        let p1 = "modulux@node.isonomia.net".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.");
        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(),
        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.");
    }
            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")));

    #[test]
    fn test_play_twice() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
        let keep_reply = command(&mut g, (p1.clone(), "keep 3".to_string()))
            .0
            crate::CancellationToken::new(),
        );
            .join("\n");
        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);
        assert!(keep_reply.contains("When you are ready, send 'guess <total>'"));
        command(&mut g, (p2.clone(), "keep 4".to_string()));
        command(&mut g, c3.clone());
        let r = command(&mut g, c3);
        assert_eq!(
            r.0.len(),
        let r = command(&mut g, (p1.clone(), "guess 7".to_string()));
        assert!(r.0.contains(&"You have guessed.".to_string()));

            1,
        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")));
    }
            "Incorrect number of replies. Reply: {:#?}.",
            r

        );

    #[tokio::test]
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You already sent me your choice. You need to wait for modulux2@node.isonomia.net

    async fn test_help_during_match() {
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(
        let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::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);
        let p1 = "modulux@node.isonomia.net".to_string();
        let p2 = "modulux2@node.isonomia.net".to_string();
        command(&mut g, (p1.clone(), "spoof".to_string()));
        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);
    }

        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));
    #[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);
        let help_reply = command(&mut g, (p1.clone(), "help".to_string()));
        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);
        assert!(help_reply.0.iter().any(|msg| msg.contains("keep <n>")));
    }

    #[test]
    fn test_join_full_then_cancel() {
    #[tokio::test]
    async fn test_parallel_matches() {
        let r;
        let mut g = Spoof::new(
        let mut g = Spoof::new(mpsc::unbounded_channel().0, CancellationToken::new());
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        let players = vec![
            "modulux@node.isonomia.net".to_string(),
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
            "modulux2@node.isonomia.net".to_string(),
            "modulux3@node.isonomia.net".to_string(),
            "modulux4@node.isonomia.net".to_string(),
        ];
        );
        r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "cancelrps".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(),
            ),
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:?}", r);
        assert_eq!(
            match_index: first_lobby as usize,
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

            command: "start_lobby".to_string(),
You're both welcome to play again any time. Use *rps* to start a new match.",
            "Incorrect response: {}",
            r.0[0]
        );
    }

        };
        g.next(&Command::InternalCommand(start_first));
    #[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()),

        // Second match
        command(&mut g, (players[2].clone(), "spoof".to_string()));
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
        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 {
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rock".to_string()),
            name: "spoof".to_string(),
        );
        r = command(
            &mut g,
            (
            match_index: second_lobby as usize,
            command: "start_lobby".to_string(),
        };
        g.next(&Command::InternalCommand(start_second));

                "modulux@node.isonomia.net".to_string(),
                "cancelrps".to_string(),
            ),
        assert_eq!(g.matches.len(), 2);
        );
        assert_eq!(r.0.len(), 1, "Incorrect length. {:?}", r);
        assert_eq!(
        assert_eq!(g.players.len(), 4);
        assert!(g
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

            .players
You're both welcome to play again any time. Use *rps* to start a new match.",
            "Incorrect response. {}.",
            r.0[0]
        );
            .get(&players[0])
            .zip(g.players.get(&players[2]))
            .map(|(a, b)| a != b)
            .unwrap_or(false));
    }
}

Added src/twothirds.rs.










































































































































































































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
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<String, Option<f64>>,
}

impl Match {
    fn new(players: &HashSet<String>) -> Self {
        let guesses = players.iter().map(|p| (p.clone(), None)).collect();
        Match { guesses }
    }

    fn waiting_players(&self) -> Vec<String> {
        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::<f64>() / 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<String> = 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<String>,
    capacity: u8,
    matches: HashMap<usize, Match>,
    players: HashMap<String, usize>,
    channel: mpsc::UnboundedSender<GameEvent>,
    token: CancellationToken,
    pending_reminders: HashSet<usize>,
    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 <number>' 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 <number>' 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 <number>' 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::<f64>() {
                                current_match
                                    .guesses
                                    .insert(command.sender.clone(), Some(parsed));
                                if current_match.is_ready() {
                                    let participants: Vec<String> =
                                        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<String> = 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 <number>' for the two-thirds game.",
                player
            ));
        }
        self.schedule_reminder(command.match_index);
        reply
    }
}

impl Game for TwoThirdsAverage {
    fn new(c: mpsc::UnboundedSender<GameEvent>, 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<Value> {
        let lobby: Vec<String> = self.lobby.iter().cloned().collect();
        let matches: Vec<TwoThirdsMatchEntry> = 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::<Vec<_>>() {
            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<String>,
    capacity: u8,
    matches: Vec<TwoThirdsMatchEntry>,
    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 <number>")));
    }

    #[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.


























































































































































































































































































































































































































































































































1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
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<String, Option<u32>>,
}

impl Match {
    fn new(players: &HashSet<String>) -> Self {
        let bids = players.iter().map(|p| (p.clone(), None)).collect();
        Match { bids }
    }

    fn waiting_players(&self) -> Vec<String> {
        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<u32, u32> = 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<String> = 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<String>,
    capacity: u8,
    matches: HashMap<usize, Match>,
    players: HashMap<String, usize>,
    channel: mpsc::UnboundedSender<GameEvent>,
    token: CancellationToken,
    pending_reminders: HashSet<usize>,
    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 <number>' (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 <number>' 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 <number>' 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::<u32>() {
                                current_match
                                    .bids
                                    .insert(command.sender.clone(), Some(parsed));
                                if current_match.is_ready() {
                                    let participants: Vec<String> =
                                        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<String> = 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 <number>' for the unique lowest bid game.",
                player
            ));
        }
        self.schedule_reminder(command.match_index);
        reply
    }
}

impl Game for UniqueLowestBid {
    fn new(c: mpsc::UnboundedSender<GameEvent>, 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<Value> {
        let lobby: Vec<String> = self.lobby.iter().cloned().collect();
        let matches: Vec<UniqueMatchEntry> = 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::<Vec<_>>() {
            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<String>,
    capacity: u8,
    matches: Vec<UniqueMatchEntry>,
    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 <number>")));
    }

    #[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);
    }
}