implement game list filtering
authorGeoffrey Allott <geoffrey@allott.email>
Sat, 13 Mar 2021 22:20:49 +0000 (22:20 +0000)
committerGeoffrey Allott <geoffrey@allott.email>
Sat, 13 Mar 2021 22:20:49 +0000 (22:20 +0000)
16 files changed:
Cargo.lock
Cargo.toml
site/img/chatroom.svg [new file with mode: 0644]
site/modules/gamelist.js
site/modules/mainmenu.js
site/style/game-list.css
src/auth.rs
src/client.rs
src/dealer.rs
src/filter.rs [deleted file]
src/game/chatroom.rs
src/game/filter.rs [new file with mode: 0644]
src/game/mod.rs
src/game/poker/holdem.rs
src/game/whist.rs
src/main.rs

index 50c7c4d47c0eaca731bba817e94dfdc2f279ea8e..6a99a0e280a1152633094882652f275422dd7054 100644 (file)
@@ -1273,6 +1273,7 @@ dependencies = [
  "serde",
  "serde_derive",
  "serde_json",
+ "sha2",
  "signal-hook",
  "signal-hook-async-std",
  "tide",
index d84cea7f97953ea16b87d0ae8a7041cf8159f2ec..05c09b6cb4a1537f537d3c28c89bb67a67e9ae47 100644 (file)
@@ -21,6 +21,7 @@ redis = { version = "0.20", features = ["async-std-tls-comp"] }
 serde = "1"
 serde_derive = "1"
 serde_json = "1"
+sha2 = "0.9"
 signal-hook = "0.3"
 signal-hook-async-std = "0.2"
 tide = { version = "0.16.0", default-features = false, features = ["h1-server"] }
diff --git a/site/img/chatroom.svg b/site/img/chatroom.svg
new file mode 100644 (file)
index 0000000..5547b38
--- /dev/null
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="225" height="350">
+    <rect x="5" y="5" width="215" height="340" fill="#fffff8" stroke="black" stroke-width="5" rx="25" />
+    <g id="text">
+        <text x="165" y="60" style="font: bold 50px serif;" fill="red">A</text>
+        <text x="150" y="120" style="font: bold 75px serif;" fill="red">♢</text>
+    </g>
+    <use href="#text" transform="rotate(180 112.5 175)" />
+</svg>
index 193e43c7bce2baab94d823b08ef8e8066fbfa431..ca0ca9972c9019828deca490271357e31374e23a 100644 (file)
@@ -23,6 +23,7 @@ export class GameList {
         game_list_outside.append(game_list_new);
         this.game_list_filter_input = document.createElement("input");
         this.game_list_filter_input.value = filter;
+        this.game_list_filter_input.onchange = () => this.send({type: "JoinLobby", filter: this.game_list_filter_input.value});
         game_list_outside.append(this.game_list_filter_input);
 
         this.container.append(game_list_outside);
@@ -165,7 +166,7 @@ export class GameList {
         format_row.append(format_title);
         const format_value = document.createElement("td");
         const format_input = document.createElement("select");
-        for (const format of ["KnockOutWhist", "TexasHoldEm"]) {
+        for (const format of ["KnockOutWhist", "TexasHoldEm", "Chatroom"]) {
             const game_format = document.createElement("option");
             game_format.setAttribute("value", format);
             game_format.innerText = format;
index b5618723bb9c708609044203afc7bdc436e413c1..019f666be7bb129f7a07f6fe66500440a2b9bd2e 100644 (file)
@@ -30,6 +30,17 @@ export class MainMenu {
         knock_out_whist.onclick = () => this.send({type: "JoinLobby", filter: "format: KnockOutWhist"});
         menu_container.append(knock_out_whist);
 
+        const chatroom = document.createElement("div");
+        chatroom.classList.add("game-format-icon");
+        const chatroom_img = document.createElement("img");
+        chatroom_img.setAttribute("src", "img/chatroom.svg");
+        const chatroom_text = document.createElement("p");
+        chatroom_text.innerText = "Chatroom";
+        chatroom.append(chatroom_img);
+        chatroom.append(chatroom_text);
+        chatroom.onclick = () => this.send({type: "JoinLobby", filter: "format: Chatroom"});
+        menu_container.append(chatroom);
+
         this.container.append(menu_container);
     }
 }
index 03d76b86fd7b42c2c0ec07f4520b4f8a80c12090..8e52758318eb6badc500388965d19db2d60b64f7 100644 (file)
     display: table-row;
 }
 
+.game-popup > table.Chatroom tr.game-field {
+    display: none;
+}
+
+.game-popup > table.Chatroom tr.game-field.Chatroom {
+    display: table-row;
+}
+
 .game-popup input, .game-popup select {
     width: 100%;
     height: 100%;
index 92db48ec471d899a076109fbea888d4031e5e730..89a9f48afdb764ede2f41094edfc79876841142f 100644 (file)
@@ -1,8 +1,15 @@
+use sha2::{Digest, Sha256};
+
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(tag = "method")]
 pub enum Auth {
     NoLogin,
     Plain { password: String },
+    Sha256 {
+        salt: String,
+        #[serde(with = "hex")]
+        hash: [u8; 32],
+    },
 }
 
 impl Auth {
@@ -10,6 +17,12 @@ impl Auth {
         match self {
             Auth::NoLogin => false,
             Auth::Plain { password } => signature == password,
+            Auth::Sha256 { salt, hash } => {
+                let mut hasher = Sha256::new();
+                hasher.update(salt.as_bytes());
+                hasher.update(signature.as_bytes());
+                hasher.finalize()[..] == hash[..]
+            }
         }
     }
 }
index 0169278704110ae612cc0131bcabd6b6d501247c..850980d069184e689603c9ea738c3c3f9dfb52a1 100644 (file)
@@ -46,9 +46,7 @@ impl ConnectionState {
                     let from = game_list.games_len();
                     match self.server.game_list(from).await {
                         Ok(games) => {
-                            for game in games.clone() {
-                                game_list.push(game);
-                            }
+                            game_list.update(games.len());
                             iter(games).map(|game| ServerMessage::NewGame { game }).boxed()
                         }
                         Err(err) => once(async move { ServerMessage::ProtocolError { reason: err.to_string() } }).boxed(),
@@ -132,12 +130,14 @@ impl ConnectionState {
                 }
             }
             (&mut ClientState::LoggedIn { username, .. }, ClientMessage::JoinLobby { filter }) => {
-                let mut game_list = GameList::new(filter.clone());
+                let mut game_list = match GameList::new(filter.clone()) {
+                    Ok(game_list) => game_list,
+                    Err(reason) => return ServerMessage::JoinLobbyFailure { reason },
+                };
                 match self.server.game_list(0).await {
                     Ok(games) => {
-                        for game in games.clone() {
-                            game_list.push(game);
-                        }
+                        game_list.update(games.len());
+                        let games = games.into_iter().filter(|game| game_list.matches(game)).collect();
                         self.client = ClientState::LoggedIn { username, state: LoggedInState::InLobby { game_list } };
                         ServerMessage::JoinLobbySuccess { filter, games }
                     }
index 61ba1cafc0f6592a15c80eb38ea53b8d046d2a6a..44a96c620c061ad425ec9575a7236c2c4a5dfcfe 100644 (file)
@@ -151,15 +151,15 @@ pub async fn spawn_dealers(server: Server, partition: Partition) -> Result<(), s
     let mut interests = HashSet::new();
     interests.insert(ClientInterest::GameList);
     server_state.register_interests(interests).await;
-    let mut game_list = GameList::new("".to_string());
+    let mut game_list = GameList::match_all();
     loop {
         let games_len = game_list.games_len();
         match server_state.game_list(games_len).await {
             Ok(games) => {
+                game_list.update(games.len());
                 for game in games {
                     info!("Starting new game {:?}", game);
                     let id = game.id();
-                    game_list.push(game);
                     if partition.start_dealer_for(id) {
                         let (server_state, update_stream) = server.new_state().await;
                         if let Ok(dealer) = Dealer::new(server_state, id).await {
diff --git a/src/filter.rs b/src/filter.rs
deleted file mode 100644 (file)
index eddcb49..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-use nom::{
-    branch::alt,
-    bytes::complete::{tag, take_until},
-    character::complete::{alpha1, multispace0},
-    combinator::map,
-    error::ParseError,
-    multi::{many0_count, separated_list1},
-    sequence::{delimited, preceded, tuple},
-    IResult,
-};
-
-fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(inner: F) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
-where
-    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
-{
-    delimited(multispace0, inner, multispace0)
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum Field<'a> {
-    Format(&'a str),
-    Title(&'a str),
-    Parens(Box<Filter<'a>>),
-}
-
-fn parse_field(input: &str) -> IResult<&str, Field> {
-    let string = || alt((alpha1, delimited(tag("\""), take_until("\""), tag("\""))));
-    let format = map(preceded(tuple((tag("format"), ws(tag(":")))), string()), Field::Format);
-    let title = map(preceded(tuple((tag("title"), ws(tag(":")))), string()), Field::Title);
-    let parens = delimited(tag("("), map(map(ws(parse_filter), Box::new), Field::Parens), tag(")"));
-    alt((format, title, parens))(input)
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct Not<'a> {
-    negate: bool,
-    field: Field<'a>,
-}
-
-fn parse_not(input: &str) -> IResult<&str, Not> {
-    map(tuple((many0_count(ws(tag("not"))), parse_field)), |(count, field)| Not { negate: count % 2 == 1, field })(input)
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct And<'a>(Vec<Not<'a>>);
-
-fn parse_and(input: &str) -> IResult<&str, And> {
-    map(separated_list1(ws(tag("and")), parse_not), And)(input)
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct Or<'a>(Vec<And<'a>>);
-
-fn parse_or(input: &str) -> IResult<&str, Or> {
-    map(separated_list1(ws(tag("or")), parse_and), Or)(input)
-}
-
-type Filter<'a> = Or<'a>;
-
-fn parse_filter(input: &str) -> IResult<&str, Filter> {
-    parse_or(input)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn field_parse() {
-        assert_eq!(Field::Format("KnockOutWhist"), parse_field("format: KnockOutWhist").unwrap().1);
-        assert_eq!(Field::Title("Superb Game"), parse_field("title: \"Superb Game\"").unwrap().1);
-    }
-
-    #[test]
-    fn filter_parse() {
-        let expected = Or(vec![
-            And(vec![Not {
-                negate: false,
-                field: Field::Parens(Box::new(Or(vec![And(vec![
-                    Not { negate: false, field: Field::Format("KnockOutWhist") },
-                    Not { negate: true, field: Field::Title("Superb Game") },
-                ])]))),
-            }]),
-            And(vec![Not { negate: false, field: Field::Format("TexasHoldEm") }]),
-        ]);
-        let actual = parse_filter("(format: KnockOutWhist and not title: \"Superb Game\") or format: TexasHoldEm").unwrap().1;
-        assert_eq!(expected, actual);
-    }
-}
index 02d7467fdfdea2b0b5682eedc83afed282268944..ea1934695fe90c175957668d30668e23cfb0ce6d 100644 (file)
@@ -26,6 +26,12 @@ pub struct ChatroomSettings {
     title: String,
 }
 
+impl ChatroomSettings {
+    pub fn title(&self) -> &str {
+        &self.title
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct Chatroom {
     id: i64,
diff --git a/src/game/filter.rs b/src/game/filter.rs
new file mode 100644 (file)
index 0000000..74658d5
--- /dev/null
@@ -0,0 +1,182 @@
+use nom::{
+    branch::alt,
+    bytes::complete::{tag, take_until},
+    character::complete::{alpha1, multispace0},
+    combinator::map,
+    error::ParseError,
+    multi::{many0_count, separated_list0, separated_list1},
+    sequence::{delimited, preceded, tuple},
+    IResult,
+};
+
+use super::{GameSettings, GameSummary};
+
+pub trait Filter {
+    fn matches(&self, summary: &GameSummary) -> bool;
+}
+
+fn ws<'a, F: 'a, O, E: ParseError<&'a str>>(inner: F) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
+where
+    F: FnMut(&'a str) -> IResult<&'a str, O, E>,
+{
+    delimited(multispace0, inner, multispace0)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Field {
+    Format(String),
+    Title(String),
+    Parens(Box<Or>),
+}
+
+impl Filter for Field {
+    fn matches(&self, summary: &GameSummary) -> bool {
+        match self {
+            Field::Format(format) => match summary.settings {
+                GameSettings::Chatroom(_) => format.eq_ignore_ascii_case("Chatroom"),
+                GameSettings::KnockOutWhist(_) => format.eq_ignore_ascii_case("KnockOutWhist"),
+                GameSettings::TexasHoldEm(_) => format.eq_ignore_ascii_case("TexasHoldEm"),
+            },
+            Field::Title(title) => summary.settings.title().to_lowercase().contains(&title.to_lowercase()),
+            Field::Parens(filter) => filter.matches(summary),
+        }
+    }
+}
+
+fn parse_field(input: &str) -> IResult<&str, Field> {
+    let string = || map(alt((alpha1, delimited(tag("\""), take_until("\""), tag("\"")))), String::from);
+    let format = map(preceded(tuple((tag("format"), ws(tag(":")))), string()), Field::Format);
+    let title = map(preceded(tuple((tag("title"), ws(tag(":")))), string()), Field::Title);
+    let parens = delimited(tag("("), map(map(ws(parse_or), Box::new), Field::Parens), tag(")"));
+    alt((format, title, parens))(input)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct Not {
+    negate: bool,
+    field: Field,
+}
+
+impl Filter for Not {
+    fn matches(&self, summary: &GameSummary) -> bool {
+        self.negate ^ self.field.matches(summary)
+    }
+}
+
+fn parse_not(input: &str) -> IResult<&str, Not> {
+    map(tuple((many0_count(ws(tag("not"))), parse_field)), |(count, field)| Not { negate: count % 2 == 1, field })(input)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct And(Vec<Not>);
+
+impl Filter for And {
+    fn matches(&self, summary: &GameSummary) -> bool {
+        self.0.iter().all(|filter| filter.matches(summary))
+    }
+}
+
+fn parse_and(input: &str) -> IResult<&str, And> {
+    map(separated_list0(ws(tag("and")), parse_not), And)(input)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct Or(Vec<And>);
+
+impl Filter for Or {
+    fn matches(&self, summary: &GameSummary) -> bool {
+        self.0.iter().any(|filter| filter.matches(summary))
+    }
+}
+
+fn parse_or(input: &str) -> IResult<&str, Or> {
+    map(ws(separated_list1(ws(tag("or")), parse_and)), Or)(input)
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ParsedFilter(Or);
+
+impl ParsedFilter {
+    pub fn match_all() -> Self {
+        ParsedFilter(Or(vec![And(vec![])]))
+    }
+}
+
+impl Filter for ParsedFilter {
+    fn matches(&self, summary: &GameSummary) -> bool {
+        self.0.matches(summary)
+    }
+}
+
+pub fn parse_filter(input: &str) -> Result<ParsedFilter, String> {
+    parse_or(input)
+        .map(|(_, filter)| ParsedFilter(filter))
+        .map_err(|err| err.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn field_parse() {
+        assert_eq!(Field::Format(String::from("KnockOutWhist")), parse_field("format: KnockOutWhist").unwrap().1);
+        assert_eq!(Field::Title(String::from("Superb Game")), parse_field("title: \"Superb Game\"").unwrap().1);
+    }
+
+    #[test]
+    fn filter_parse() {
+        let expected = Or(vec![
+            And(vec![Not {
+                negate: false,
+                field: Field::Parens(Box::new(Or(vec![And(vec![
+                    Not { negate: false, field: Field::Format(String::from("KnockOutWhist")) },
+                    Not { negate: true, field: Field::Title(String::from("Superb Game")) },
+                ])]))),
+            }]),
+            And(vec![Not { negate: false, field: Field::Format(String::from("TexasHoldEm")) }]),
+        ]);
+        let actual = parse_or("(format: KnockOutWhist and not title: \"Superb Game\") or format: TexasHoldEm").unwrap().1;
+        assert_eq!(expected, actual);
+    }
+
+    #[test]
+    fn filter_matches() {
+        let filter = parse_filter("format: KnockOutWhist and (title:Game or title:Play)").unwrap();
+        let games: Vec<GameSummary> = serde_json::from_str(
+            r#"[
+            {"id": 0, "settings": {"format":"KnockOutWhist","title":"Game 0","max_players":2}},
+            {"id": 1, "settings": {"format":"KnockOutWhist","title":"game 1","max_players":2}},
+            {"id": 2, "settings": {"format":"Chatroom","title":"Game 2"}},
+            {"id": 3, "settings": {"format":"KnockOutWhist","title":"Invalid 3","max_players":2}},
+            {"id": 4, "settings": {"format":"KnockOutWhist","title":"play 4","max_players":2}}
+        ]"#,
+        )
+        .unwrap();
+        assert!(filter.matches(&games[0]));
+        assert!(filter.matches(&games[1]));
+        assert!(!filter.matches(&games[2]));
+        assert!(!filter.matches(&games[3]));
+        assert!(filter.matches(&games[4]));
+    }
+
+    #[test]
+    fn empty_filter_matches() {
+        let filter = parse_filter("   ").unwrap();
+        let games: Vec<GameSummary> = serde_json::from_str(
+            r#"[
+            {"id": 0, "settings": {"format":"KnockOutWhist","title":"Game 0","max_players":2}},
+            {"id": 1, "settings": {"format":"KnockOutWhist","title":"game 1","max_players":2}},
+            {"id": 2, "settings": {"format":"Chatroom","title":"Game 2"}},
+            {"id": 3, "settings": {"format":"KnockOutWhist","title":"Invalid 3","max_players":2}},
+            {"id": 4, "settings": {"format":"KnockOutWhist","title":"play 4","max_players":2}}
+        ]"#,
+        )
+        .unwrap();
+        assert!(filter.matches(&games[0]));
+        assert!(filter.matches(&games[1]));
+        assert!(filter.matches(&games[2]));
+        assert!(filter.matches(&games[3]));
+        assert!(filter.matches(&games[4]));
+    }
+}
index a11567734a51ba6f3345a19e41e00878e293c7fa..3fea22f0eb1713965a5df7f40805913626d0db42 100644 (file)
@@ -1,5 +1,6 @@
 mod action;
 mod chatroom;
+mod filter;
 mod poker;
 mod whist;
 
@@ -11,6 +12,7 @@ use crate::username::Username;
 use crate::util::timestamp::Timestamp;
 
 use self::chatroom::{Chatroom, ChatroomSettings};
+use self::filter::{parse_filter, Filter, ParsedFilter};
 use self::poker::{TexasHoldEm, TexasHoldEmSettings};
 use self::whist::{KnockOutWhist, KnockOutWhistSettings};
 
@@ -59,24 +61,42 @@ pub enum GameSettings {
     TexasHoldEm(TexasHoldEmSettings),
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "format")]
+impl GameSettings {
+    pub fn title(&self) -> &str {
+        match self {
+            GameSettings::Chatroom(settings) => settings.title(),
+            GameSettings::KnockOutWhist(settings) => settings.title(),
+            GameSettings::TexasHoldEm(settings) => settings.title(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
 pub struct GameList {
-    filter: String,
-    games: Vec<GameSummary>,
+    filter: ParsedFilter,
+    games_len: usize,
 }
 
 impl GameList {
-    pub fn new(filter: String) -> Self {
-        Self { filter, games: Vec::new() }
+    pub fn new(filter: String) -> Result<Self, String> {
+        let filter = parse_filter(&filter)?;
+        Ok(Self { filter, games_len: 0 })
+    }
+
+    pub fn match_all() -> Self {
+        Self { filter: ParsedFilter::match_all(), games_len: 0 }
     }
 
     pub fn games_len(&self) -> usize {
-        self.games.len()
+        self.games_len
+    }
+
+    pub fn update(&mut self, new_games: usize) {
+        self.games_len += new_games;
     }
 
-    pub fn push(&mut self, game: GameSummary) {
-        self.games.push(game);
+    pub fn matches(&self, game: &GameSummary) -> bool {
+        self.filter.matches(&game)
     }
 }
 
index a2f62f5683cd923b26eae3eee1996bd451095146..56796d57149e3763911d3f3cb193a5f4fc91234a 100644 (file)
@@ -40,6 +40,12 @@ pub struct TexasHoldEmSettings {
     action_timeout: Option<i64>,
 }
 
+impl TexasHoldEmSettings {
+    pub fn title(&self) -> &str {
+        &self.title
+    }
+}
+
 #[derive(Clone, Debug)]
 pub struct TexasHoldEm {
     id: i64,
index dc7d7bc49c77146225485e26f459fd4616867c09..a383953931f9eeff2a47d95f2f8205a228fed66e 100644 (file)
@@ -25,6 +25,12 @@ pub struct KnockOutWhistSettings {
     max_players: u32,
 }
 
+impl KnockOutWhistSettings {
+    pub fn title(&self) -> &str {
+        &self.title
+    }
+}
+
 #[derive(Clone, Debug)]
 pub struct KnockOutWhist {
     id: i64,
index c921544a26fc5db7116918615c9a295958b9585d..c4f4e963c3d4bab0765a719a430a2f030234e738 100644 (file)
@@ -28,7 +28,6 @@ mod card;
 mod client;
 mod config;
 mod dealer;
-mod filter;
 mod game;
 mod pubsub;
 mod rng;