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 1f0e07400c to d7057bb24d

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
08:17
Merged improvements made on Rps and Dice into the Spoof branch and got things in order. Leaf check-in: d7057bb24d user: david tags: spoof
2023-11-06
08:09
Restructured event loop to use a single channel that receives fedi API notifications game-generated events. Got the tests that were passing, passing again. check-in: 846461bf2e user: david tags: spoof
2023-10-31
12:57
New release adding dice rolling. check-in: 1f0e07400c user: david tags: trunk
2023-10-30
21:51
Further fixes to the regexp and the logic. check-in: 11d59e8c64 user: david tags: trunk, v1

Changes to Cargo.toml.

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
20
21
22
23
24
25
26

27
28
29
30
31
32
33







-







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"]}
rand = "0.8.5"


# The following lines are optimising for a small binary, given a bot spends most of its time idling in RAM.
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1

Changes to README.md.

48
49
50
51
52
53
54

55
56
57
58
48
49
50
51
52
53
54
55
56
57
58
59







+





* My fedi account, [@modulux@node.isonomia.net](https://node.isonomia.net/@modulux).
* The [fossil repository](https://modulus.isonomia.net/pravda.cgi) for the project.
* If you must, email me at modulus at isonomia dot net.

## Change log.

* V 0.2.0: Spoof is implemented.
* V 0.1.3: dice can be rolled with the dice command, accepting an ndm optional parameter.
* V 0.1.2: exponential backoff system for cases where the streaming API fails. Assorted typographical corrections.
* V 0.1.1: set a loop around the toot streaming in case it errors out.
* V 0.1.0: initial release.

Changes to src/dice.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

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
-
+






-
-
+
+
+
+
+




-
+
+



+
+
+
+
+
+







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

pub struct Dice;

impl Game for Dice {
    fn new() -> Self {
        Dice
    fn new(
        _: tokio::sync::mpsc::UnboundedSender<GameEvent>,
        _: tokio_util::sync::CancellationToken,
    ) -> Self {
    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.")
            Regex::new(r"^(?:dice|dado)(?: (\d+)(?:d(\d+))?)?$")
                .expect("Problem building dice regexp.")
        });
        let mut rng = rand::thread_rng();
        let mut gen = |n1, n2| rng.gen_range(n1..=n2);
        let m = match m {
            Command::PlayerCommand(c) => c,
            Command::InternalCommand(_) => {
                return r;
            }
        };

        if let Some(caps) = DICE.captures(&m.content) {
            let l = caps.iter().skip(1).filter(|c| c.is_some()).count();
            // println!("{}: {:?}", l, caps);
            match l {
                0 => {
                    r.push(format!(

Changes to src/game.rs.

1
2
3
4
5
6
7
8
9




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

9
10
11
12
13
14
15
16
17
18
19








-
+
+
+
+







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.
pub trait Game: Send {
    /// A static method to create a new game.
    fn new() -> Self
    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.
    fn next(&mut self, m: &Command) -> Reply;
}

/// A game response. For now just a vector of strings that must be tooted.
27
28
29
30
31
32
33
34
35


36
37
38

























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







-
-
+
+



+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
    }
    /// Set visibility to direct messages.
    pub fn quiet(&mut self) {
        self.1 = Visibility::Direct;
    }
}

/// A command. This is what we receive at next().
pub struct Command {
/// A command from a player: sender and content.
pub struct PlayerCommand {
    pub sender: String,
    pub content: String,
}

/// An internal command: contains the match it is for, and a string.
pub struct InternalCommand {
    pub name: String,
    pub match_index: usize,
    pub command: String,
}

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

/// An event.
pub enum GameEvent {
    /// A response to send.
    Reply(Reply),
    /// Or an Internal Command..
    Step(InternalCommand),
    /// Or a fedi notification.
    Notification(crate::Notification),
}

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

-
+




















+







use futures_util::TryStreamExt;
use game::{Command, Game};
use game::{Command, Game, GameEvent, PlayerCommand};
use mastodon_async::{
    entities::notification::NotificationType,
    helpers::{cli, toml},
    prelude::*,
    Language, Result, StatusBuilder,
};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::ClientBuilder;
use std::cmp::min;
use tokio::{
    select,
    sync::mpsc,
    time::{sleep, Duration},
};
use tokio_util::sync::CancellationToken;

mod dice;
mod game;
mod rps;
mod spoof;

/// Admin account. It can send administrative commands.
const ADMIN: &str = "modulux@node.isonomia.net";

/// Quit command.
const QUIT: &str = "!QUIT!";

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







+
+
+
+
+

-
+



-
-
+
+
-
-












+

+




-
+













-
-
+



-
+










-







}

#[tokio::main]
async fn main() -> Result<()> {
    let token = CancellationToken::new();
    let token2 = token.clone();

    // This channel sends events from the fedi API and game events to the main event loop.
    let (cs, mut cr) = mpsc::unbounded_channel();
    let spoof = spoof::Spoof::new(cs.clone(), token.clone());
    let dice = dice::Dice::new(cs.clone(), token.clone());

    // g is a vector of games.
    let mut g: Vec<Box<dyn Game>> = vec![Box::new(rps::Rps::new())];
    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(rps::Rps::new()));
    g.push(Box::new(dice::Dice::new()));
    g.push(Box::new(spoof));
    g.push(Box::new(dice));

    let (cs, mut cr) = mpsc::unbounded_channel();

    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") {
        Mastodon::new(web_client, data)
    } else {
        register().await?
    };

    // We need one mastodon object for the stream and another to post statuses through.
    let m = mastodon.clone();
    // Event loop, runs on another thread.
    tokio::spawn(async move {
        println!("Initiating main event loop.");
        while let Some(event) = cr.recv().await {
            match event {
                Event::Notification(n) if n.notification_type == NotificationType::Mention => {
                GameEvent::Notification(n) if n.notification_type == NotificationType::Mention => {
                    let sender = n.account.acct;
                    // Time to check if we got a quit message.
                    let s = n
                        .status
                        .expect("Event is a notification but does not have a status.");
                    let content = clean(&s.content).await;
                    if sender == ADMIN && s.content.contains(QUIT) {
                        println!("Admin sent us the shut down command.");
                        token.cancel();
                        break;
                    }
                    print_status(&s).await;
                    // Treat the toot  like a command.
                    let id = s.id;
                    let comm = Command { sender, content };
                    let comm = PlayerCommand { sender, content };
                    // We iterate on each game.
                    for i in g.iter_mut() {
                        // Asking it if we must reply.
                        let r = i.next(&comm);
                        let r = i.next(&Command::PlayerCommand(&comm));
                        // And for each reply.
                        // Take the visibility.
                        let v = r.1;
                        for j in r.0 {
                            // We send it.
                            println!("Sent: {}", j);
                            let status = StatusBuilder::new()
                                .status(j.to_string())
                                .language(Language::Eng)
                                .visibility(v)
                                .in_reply_to(id.to_string())
                                .build();
                            let _ =
                                post_status(&m, status.expect("Problem posting a status.")).await;
                        }
                    }
                }
                _ => (),
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
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







-
-
+
+













+
-
+
+



-
+
-
-
+
-
-
+
+




-
+

-
+
+
+







                }
                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);
                    println!("Backing off level now {}.", bl);
                }
                continue;
            }
        };

        select! {
        _ = token2.cancelled() =>
        {
        println!("Shutting down...");
        break 'stream;
        }

        _ =
        stream.try_for_each(|(event, _)| async {
        if let Event::Notification(n) = event {
        cs.send(event).expect("Problem sending event through channel.");
        cs.send(GameEvent::Notification(n)).expect("Problem sending event through channel.");
        }
        Ok(())
        })
        => {
        if bl < MAX_BL {
        if bl < MAX_BL { bl += 1; }
            bl += 1;
            println!("Streaming terminated. Backing off level now {}", bl);
        println!("Streaming terminated. Backing off level now {}", bl);
        }}
        None = async {
        }
        Some(()) = async {
        loop {
        sleep(Duration::from_secs(30)).await;
        if bl > 0 {
            bl -= 1;
            println!("Backing off level now {}.", bl);}
            println!("Backing off level now {}.", bl);
        }
        Some(())
        // Return value for the type checker.
        if false { return None }
        }
        }
        =>
        {}
        };
        println!("Streaming terminated. Loop: {}. Backing off: {}", l, bl);
        l += 1;
    }

Changes to src/rps.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



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
-
-
-
+
+
+
+
+








+








+




+








-
+







use crate::game::{Command, Game, Reply};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use crate::game::{Command, Game, GameEvent, Reply};
use std::{
    cmp::Ordering,
    collections::{HashMap, HashSet},
};
/// Rock, Paper, Scissors.

/// A match. Considering to move this to Game.
struct Match {
    state: HashMap<String, Option<Play>>,
}

impl Match {
    /// Create a new match from the set of players in the lobby.
    fn new(h: &HashSet<String>) -> Self {
        let mut n = HashMap::new();
        for key in h {
            n.insert(key.clone(), None);
        }
        Match { state: n }
    }

    /// Determine if the game is ready for solution.
    fn is_ready(&self) -> bool {
        self.state.values().all(|v| v.is_some())
    }

    /// Return the opponent of a player in an ongoing match.
    fn opponent(&self, s: &String) -> String {
        self.state
            .keys()
            .find(|k| k != &s)
            .expect("Getting opponent, player name not in the set.")
            .to_string()
    }

    // Solves the game. MUST never call this method if there aren't two plays made.
    /// Solves the game. MUST never call this method if there aren't two plays made.
    fn solve(&self) -> Reply {
        // Our reply.
        let mut r = Reply::new();
        // Players.
        let mut p = Vec::new();
        // And their choices:
        let mut c = Vec::new();
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
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







-
+

-
+





-
+
+
+
+











+
+
+
+
+
+
+




















-
-
-
-
-








/// 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.
    /// A vector of ongoing matches.
    matches: Vec<Match>,
    // HashSet indicating for each player which match they are in.
    /// HashSet indicating for each player which match they are in.
    players: HashMap<String, usize>,
}

impl Game for Rps {
    /// Creation of a new and empty Rps game structure.
    fn new() -> Self {
    fn new(
        c: tokio::sync::mpsc::UnboundedSender<GameEvent>,
        token: tokio_util::sync::CancellationToken,
    ) -> Self {
        Rps {
            lobby: HashSet::new(),
            capacity: 2,
            matches: Vec::new(),
            players: HashMap::new(),
        }
    }

    /// State machine that accepts a command, changes state and delivers replies if required.
    fn next(&mut self, m: &Command) -> Reply {
        let mut r = Reply::new();
        let m = match m {
            Command::PlayerCommand(c) => c,
            Command::InternalCommand(_) => {
                return r;
            }
        };

        // The entire state depends on two factors: are we waiting to join a game, and are we playing a game?
        // It is possible for both to be false, and either one to be true.
        // If both are true, this is an error.
        let waiting = self.lobby.contains(&m.sender);
        let playing = self.players.contains_key(&m.sender);
        assert!(!(waiting & playing));
        // It is possible for a command to be: rps, a move in the game, or cancelrps.
        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.
        /*         println!(
                    "{}, {}, {:?}, {}, {}.",
                    joining, quitting, choice, playing, waiting
                );
        */
        match (joining, quitting, choice, playing, waiting) {
            // We don't bother with the impossible cases that are already excluded by assertion.
            // Let's start with joining the game.
            // There are 3 cases we need care about:
            // We're joining, not playing and not waiting.
            (true, false, None, false, false) => {
                // This has two cases.
274
275
276
277
278
279
280
281

282
283
284
285
286
287
288
289
290

291
292
293
294
295
296
297
284
285
286
287
288
289
290

291
292
293
294
295
296
297
298
299

300
301
302
303
304
305
306
307







-
+








-
+







            _ => {} // __
        }

        r
    }
}

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

impl Play {
    // Silly sound effects.
    /// Silly sound effects.
    fn noise(&self) -> String {
        match self {
            Play::Rock => "CRUNCH!",
            Play::Scissors => "SNIP!",
            Play::Paper => "CRUMPLE!",
        }
        .to_string()
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
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







-
+

+
+
+
+
+
+
+



-
+
+
+
+






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




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











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











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











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














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







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



-
+



-
-
-
-
-
-




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














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











        }
    }
}

/// Tests.
#[cfg(test)]
mod test {
    use crate::game::{Command, Game};
    use crate::game::{Command, Game, PlayerCommand, Reply};
    use crate::rps::Rps;

    fn command(g: &mut dyn Game, m: (String, String)) -> Reply {
        let (sender, content) = m;
        let p = PlayerCommand { sender, content };
        let r = g.next(&Command::PlayerCommand(&p));
        r
    }

    #[test]
    fn test_new() {
        let g = Rps::new();
        let g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        assert_eq!(g.capacity, 2);
        assert_eq!(g.lobby.len(), 0);
    }

    #[test]
    fn test_nonsense() {
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c: Command = Command {
            sender: "modulux@node.isonomia.net".to_string(),
            content: "nonsense".to_string(),
        let r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "nonsense".to_string(),
        };
        let mut g = Rps::new();
        assert!(g.next(&c).0.is_empty());
            ),
        );
        assert!(r.0.is_empty());
    }

    #[test]
    fn test_first_join_game() {
        let mut g = Rps::new();
        let c = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let r = command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
            content: "rps".to_string(),
        };
        let r = g.next(&c);
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {}.", r.0.len());
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You've asked to join a game of Rock, Paper, Scissors. As soon as someone else wants to play, I'll send you a message so you can tell me your choice.".to_string(), "Incorrect reply message: {}.", r.0[0]);
        assert_eq!(
            g.capacity, 1,
            "Capacity in lobby should be 1, is {}.",
            g.capacity
        );
    }

    #[test]
    fn test_join_game_twice() {
        let mut g = Rps::new();
        let c = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
            content: "rps".to_string(),
        };
        g.next(&c);
        let r = g.next(&c);
        );
        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 = Rps::new();
        let c1 = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string());
            content: "rps".to_string(),
        };
        g.next(&c1);
        let c2 = Command {
            sender: "modulux2@node.isonomia.net".to_string(),
        command(&mut g, c1);
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string());
            content: "rps".to_string(),
        };
        let r = g.next(&c2);
        let r = command(&mut g, c2);
        assert_eq!(r.0.len(), 2, "Incorrect number of replies: {}.", r.0.len());
        assert!(r.0.contains(&"@modulux@node.isonomia.net Got a partner! Your opponent is modulux2@node.isonomia.net

Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply.");
        assert!(r.0.contains(&"@modulux2@node.isonomia.net Got a partner! Your opponent is modulux@node.isonomia.net

Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply.");
    }

    #[test]
    fn test_play_twice() {
        let mut g = Rps::new();
        let c1 = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string());
            content: "rps".to_string(),
        };
        let c2 = Command {
            sender: "modulux2@node.isonomia.net".to_string(),
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string());
            content: "rps".to_string(),
        };
        let c3 = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let c3 = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
            content: "rock".to_string(),
        };
        g.next(&c1);
        g.next(&c2);
        g.next(&c3);
        let r = g.next(&c3);
        command(&mut g, c1);
        command(&mut g, c2);
        command(&mut g, c3.clone());
        let r = command(&mut g, c3);
        assert_eq!(
            r.0.len(),
            1,
            "Incorrect number of replies. Reply: {:#?}.",
            r
        );

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

If you get bored, you can send me *cancelrps* to cancel the game.", "Incorrect reply: {}.", r.0[0]);
    }

    #[test]
    fn test_play_too_early() {
        let mut g = Rps::new();
        let c = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
            content: "rock".to_string(),
        };
        let r = g.next(&c);
        let r = command(&mut g, c);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);
    }

    #[test]
    fn twice_early_play() {
        let mut r;
        let mut g = Rps::new();
        let c1 = Command {
            sender: "modulux@node.isonomia.net".to_string(),
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
            content: "rock".to_string(),
        };
        let c2 = Command {
            sender: "modulux2@node.isonomia.net".to_string(),
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rock".to_string());
            content: "rock".to_string(),
        };
        r = g.next(&c1);
        r = command(&mut g, c1);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);

        r = g.next(&c2);
        r = command(&mut g, c2);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux2@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);
    }
    fn command(sender: &str, content: &str) -> Command {
        Command {
            sender: sender.to_string(),
            content: content.to_string(),
        }
    }

    #[test]
    fn test_join_full_then_cancel() {
        let r;
        let mut g = Rps::new();
        g.next(&command("modulux@node.isonomia.net", "rps"));
        g.next(&command("modulux2@node.isonomia.net", "rps"));
        r = g.next(&command("modulux@node.isonomia.net", "cancelrps"));
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
        );
        r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "cancelrps".to_string(),
            ),
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:?}", r);
        assert_eq!(
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

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

    #[test]
    fn test_join_full_play_then_cancel() {
        let r;
        let mut g = Rps::new();
        g.next(&command("modulux@node.isonomia.net", "rps"));
        g.next(&command("modulux2@node.isonomia.net", "rps"));
        g.next(&command("modulux@node.isonomia.net", "rock"));
        r = g.next(&command("modulux@node.isonomia.net", "cancelrps"));
        let mut g = Rps::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rock".to_string()),
        );
        r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "cancelrps".to_string(),
            ),
        );
        assert_eq!(r.0.len(), 1, "Incorrect length. {:?}", r);
        assert_eq!(
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

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

Added 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
use crate::{
    game::{Command, Game, GameEvent, InternalCommand, PlayerCommand, Reply},
    sleep, Duration, Lazy, Regex,
};
use std::collections::{HashMap, HashSet};
/// Spoof.

/// A match. Considering to move this to Game.
/// TODO: think about locales.
struct Match {
    state: HashMap<String, Option<Play>>,
}

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

    fn is_ready(&self) -> bool {
        self.state.values().all(|v| v.is_some())
    }
}

/// Data structure of the game.
pub struct Spoof {
    /// name of the game.
    name: String,
    /// A HashSet with the players waiting to play as account strings.
    lobby: HashSet<String>,
    /// capacity determines  how many people a match contains.
    capacity: u8,
    /// A vector of ongoing matches.
    matches: Vec<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,
}

impl Spoof {
    /// Handling a player command.
    fn player(&mut self, m: &PlayerCommand) -> Reply {
        // Our reply.
        let mut r = Reply::new();
        // Are we in the lobby?
        let waiting = self.lobby.contains(&m.sender);
        // Are we playingg?
        let playing = self.players.contains_key(&m.sender);
        // Spoof has three commands: spoof to join, keep plus a number to determine how many coins you keep in your
        // hand, and guess plus a number to make your guess.
        // Split command into words:
        let words: Vec<_> = m.content.split_whitespace().collect();
        // match commands.
        match words[..] {
            ["spoof"] => {
                if waiting {
                    r.push(format!(
                        "@{} You're already waiting for a game of spoof. Be patient.",
                        &m.sender,
                    ));
                } else if playing {
                    r.push(format!(
                        "@{} You're already playing a game of spoof.",
                        &m.sender
                    ));
                } else {
                    if self.capacity > 1 {
                        r.push(format!("@{} You are now waiting for a game of spoof. The game will begin when there are 10 players, or when there are at least 3 players and nobody has joined in 5 minutes.", &m.sender));
                        self.lobby.insert(m.sender.clone());
                        self.capacity -= 1;
                    } else {
                        // Lobby's full. Game must start.
                        self.lobby.insert(m.sender.clone());
                        let players = self.lobby.clone();
                        let this_match = Match::new(&players);
                        self.lobby = HashSet::new();
                        self.capacity = 10;
                        self.matches.push(this_match);
                        let n = self.matches.len() - 1;
                        for i in players {
                            self.players.insert(i, n);
                        }
                        // Send start command.
                        self.channel
                            .send(GameEvent::Step(InternalCommand {
                                name: self.name.clone(),
                                match_index: n,
                                command: "start".to_string(),
                            }))
                            .expect("Problem sending event through channel.");
                    }
                }
            }
            _ => {}
        }
        r
    }

    /// Handling an internal command.
    fn internal(&mut self, m: &InternalCommand) -> Reply {
        let mut r = Reply::new();
        if m.name == self.name {}
        r
    }
}

impl Game for Spoof {
    /// Creation of a new and empty Spoof game structure.
    fn new(
        c: tokio::sync::mpsc::UnboundedSender<GameEvent>,
        token: tokio_util::sync::CancellationToken,
    ) -> Self {
        Spoof {
            name: "spoof".to_string(),
            lobby: HashSet::new(),
            capacity: 10,
            matches: Vec::new(),
            players: HashMap::new(),
            channel: c,
            token: token,
        }
    }

    /// State machine that accepts a command, changes state and delivers replies if required.
    fn next(&mut self, m: &Command) -> Reply {
        let mut r = Reply::new();
        r = match m {
            Command::PlayerCommand(p) => self.player(p),
            Command::InternalCommand(i) => self.internal(i),
        };
        r
    }
}

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

/// Tests.
#[cfg(test)]
mod test {
    use crate::game::{Command, Game, PlayerCommand, Reply};
    use crate::spoof::Spoof;

    fn command(g: &mut dyn Game, m: (String, String)) -> Reply {
        let (sender, content) = m;
        let p = PlayerCommand { sender, content };
        let r = g.next(&Command::PlayerCommand(&p));
        r
    }

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

    #[test]
    fn test_nonsense() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "nonsense".to_string(),
            ),
        );
        assert!(r.0.is_empty());
    }

    #[test]
    fn test_first_join_game() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let r = command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "spoof".to_string()),
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {}.", r.0.len());
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You are now waiting for a game of spoof. The game will begin when there are 10 players, or when there are at least 3 players and nobody has joined in 5 minutes.".to_string(), "Incorrect reply message: {}.", r.0[0]);
        assert_eq!(
            g.capacity, 9,
            "Capacity in lobby should be 1, is {}.",
            g.capacity
        );
    }

    #[test]
    fn test_join_game_twice() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        let r = command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:}.", r.0.len());
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You're already waiting for a game of Rock, Paper, Scissors. Be patient.".to_string(), "Incorrect reply message: {}.", r.0[0]);
        assert_eq!(
            g.capacity, 1,
            "Capacity in lobby should be 1, is {}.",
            g.capacity
        );
    }

    #[test]
    fn test_join_game_complete() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string());
        command(&mut g, c1);
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string());
        let r = command(&mut g, c2);
        assert_eq!(r.0.len(), 2, "Incorrect number of replies: {}.", r.0.len());
        assert!(r.0.contains(&"@modulux@node.isonomia.net Got a partner! Your opponent is modulux2@node.isonomia.net

Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply.");
        assert!(r.0.contains(&"@modulux2@node.isonomia.net Got a partner! Your opponent is modulux@node.isonomia.net

Tell me your choice: *rock*, *paper*, or *scissors*?".to_string()), "Missing reply.");
    }

    #[test]
    fn test_play_twice() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rps".to_string());
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rps".to_string());
        let c3 = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
        command(&mut g, c1);
        command(&mut g, c2);
        command(&mut g, c3.clone());
        let r = command(&mut g, c3);
        assert_eq!(
            r.0.len(),
            1,
            "Incorrect number of replies. Reply: {:#?}.",
            r
        );

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

If you get bored, you can send me *cancelrps* to cancel the game.", "Incorrect reply: {}.", r.0[0]);
    }

    #[test]
    fn test_play_too_early() {
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
        let r = command(&mut g, c);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);
    }

    #[test]
    fn twice_early_play() {
        let mut r;
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        let c1 = ("modulux@node.isonomia.net".to_string(), "rock".to_string());
        let c2 = ("modulux2@node.isonomia.net".to_string(), "rock".to_string());
        r = command(&mut g, c1);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);

        r = command(&mut g, c2);
        assert_eq!(r.0.len(), 1, "Incorrect number of replies in: {:?}", r);
        assert_eq!(r.0[0], "@modulux2@node.isonomia.net You haven't joined a game yet. You can do so by sending me *rps* whenever you like.", "{:?}", r);
    }

    #[test]
    fn test_join_full_then_cancel() {
        let r;
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
        );
        r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "cancelrps".to_string(),
            ),
        );
        assert_eq!(r.0.len(), 1, "Incorrect number of replies: {:?}", r);
        assert_eq!(
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

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

    #[test]
    fn test_join_full_play_then_cancel() {
        let r;
        let mut g = Spoof::new(
            tokio::sync::mpsc::unbounded_channel().0,
            crate::CancellationToken::new(),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux2@node.isonomia.net".to_string(), "rps".to_string()),
        );
        command(
            &mut g,
            ("modulux@node.isonomia.net".to_string(), "rock".to_string()),
        );
        r = command(
            &mut g,
            (
                "modulux@node.isonomia.net".to_string(),
                "cancelrps".to_string(),
            ),
        );
        assert_eq!(r.0.len(), 1, "Incorrect length. {:?}", r);
        assert_eq!(
            r.0[0],
            "@modulux@node.isonomia.net has cancelled the game with @modulux2@node.isonomia.net

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