can play through whole game of knock-out whist through web interface
authorGeoffrey Allott <geoffrey@allott.email>
Thu, 25 Feb 2021 23:49:33 +0000 (23:49 +0000)
committerGeoffrey Allott <geoffrey@allott.email>
Thu, 25 Feb 2021 23:49:33 +0000 (23:49 +0000)
site/img/suit-c.svg [new file with mode: 0644]
site/img/suit-d.svg [new file with mode: 0644]
site/img/suit-h.svg [new file with mode: 0644]
site/img/suit-s.svg [new file with mode: 0644]
site/modules/card.js
site/modules/socket.js
site/style/whist.css
src/dealer.rs
src/game/mod.rs
src/game/whist.rs

diff --git a/site/img/suit-c.svg b/site/img/suit-c.svg
new file mode 100644 (file)
index 0000000..07704cb
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+    <text x="17.5" y="75" style="font: bold 75px serif;">♣</text>
+</svg>
diff --git a/site/img/suit-d.svg b/site/img/suit-d.svg
new file mode 100644 (file)
index 0000000..ac979d5
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+    <text x="17.5" y="75" style="font: bold 75px serif;" fill="red">♢</text>
+</svg>
diff --git a/site/img/suit-h.svg b/site/img/suit-h.svg
new file mode 100644 (file)
index 0000000..b257083
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+    <text x="17.5" y="75" style="font: bold 75px serif;" fill="red">♡</text>
+</svg>
diff --git a/site/img/suit-s.svg b/site/img/suit-s.svg
new file mode 100644 (file)
index 0000000..0c2c641
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
+    <text x="17.5" y="75" style="font: bold 75px serif;">♠</text>
+</svg>
index 2422de543bd21b6d99ede546791f94f4afadfb75..cceadbf5b8e7608f371d52c9751bb073806d2bc9 100644 (file)
@@ -29,3 +29,7 @@ export function card_href(card) {
     if (card === null) return "img/card-back-blue.svg";
     return "img/card-" + short_rank(card.rank) + short_suit(card.suit) + ".svg";
 }
+
+export function suit_href(suit) {
+    return "img/suit-" + short_suit(suit) + ".svg";
+}
index 72ec7fb59a08089c59328a9578c23715ef8c0c1d..b6f098ea16d7c87e8f9bd7398b6ca97c042f1d9b 100644 (file)
@@ -1,6 +1,6 @@
 const svgns = "http://www.w3.org/2000/svg";
 
-import { card_href } from "./card.js";
+import { card_href, suit_href } from "./card.js";
 
 export class Socket {
     constructor(container, login_all_sockets) {
@@ -264,6 +264,36 @@ export class Socket {
         felt.setAttribute("fill", "green");
         this.svg.append(felt);
 
+        const suits = document.createElementNS(svgns, "rect");
+        suits.setAttribute("x", "210");
+        suits.setAttribute("y", "400");
+        suits.setAttribute("width", "100");
+        suits.setAttribute("height", "30");
+        suits.setAttribute("fill", "#00000000");
+        this.svg.append(suits);
+
+        this.glyphs = new Map();
+        let x = 210;
+        for (const suit of ["Clubs", "Diamonds", "Hearts", "Spades"]) {
+            const highlight = document.createElementNS(svgns, "circle");
+            highlight.setAttribute("cx", x + 12.5);
+            highlight.setAttribute("cy", "415");
+            highlight.setAttribute("r", "10");
+            highlight.classList.add("suit-highlight");
+            highlight.onclick = () => this.take_action({action: "ChooseTrumps", suit: suit});
+            this.svg.append(highlight);
+            this.glyphs.set(suit, highlight);
+            const glyph = document.createElementNS(svgns, "image");
+            glyph.setAttribute("x", x);
+            glyph.setAttribute("y", "400");
+            glyph.setAttribute("width", "25");
+            glyph.setAttribute("height", "30");
+            glyph.setAttribute("href", suit_href(suit));
+            glyph.classList.add("suit-glyph");
+            this.svg.append(glyph);
+            x += 25;
+        }
+
         this.container.append(this.svg);
         this.redraw_knock_out_whist();
     }
@@ -353,6 +383,13 @@ export class Socket {
         return image;
     }
 
+    set_trumps(suit) {
+        this.game.trump_suit = suit;
+        for (const [suit, glyph] of this.glyphs) {
+            glyph.classList.toggle("trumps", suit === this.game.trump_suit);
+        }
+    }
+
     add_action(user_action, initialising) {
         switch (this.game.summary.settings.format) {
             case "Chatroom":
@@ -396,12 +433,27 @@ export class Socket {
                         break;
                     case "NextToDeal":
                         this.dealer = user_action.username;
+                        for (const card of this.game.community) {
+                            this.svg.removeChild(card.image);
+                        }
+                        this.game.community = [];
+                        for (const [username, hand] of this.game.hands) {
+                            for (const card of hand) {
+                                this.svg.removeChild(card.image);
+                            }
+                        }
+                        this.game.hands.clear();
+                        this.set_trumps(null);
                         break;
                     case "ReceiveCard":
+                    case "RevealCard":
                         const card = {
                             card: user_action.action.card,
                             image: this.card_image(user_action.username, user_action.action.card),
                         };
+                        if (!this.game.hands.has(user_action.username)) {
+                            this.game.hands.set(user_action.username, []);
+                        }
                         this.game.hands.get(user_action.username).push(card);
                         this.set_card_positions();
                         break;
@@ -426,8 +478,9 @@ export class Socket {
                         });
                         this.game.hands.set(user_action.username, cards);
                         this.set_card_positions();
+                        break;
                     case "ChooseTrumps":
-                        this.game.trump_suit = user_action.action.suit;
+                        this.set_trumps(user_action.action.suit);
                         break;
                     case "CommunityCard":
                         const community_card = {
@@ -435,7 +488,8 @@ export class Socket {
                             image: this.card_image(null, user_action.action.card),
                         };
                         this.game.community.push(community_card);
-                        this.game.trump_suit = user_action.action.card.suit;
+                        this.set_trumps(user_action.action.card.suit);
+                        this.set_card_positions();
                         break;
                     case "WinTrick":
                         for (const card of this.game.trick) {
@@ -446,6 +500,10 @@ export class Socket {
                     case "EndDeal":
                         console.log(this);
                         break;
+                    case "WinGame":
+                        console.log(this);
+                        this.set_trumps(null);
+                        break;
                     default:
                         console.error("Unhandled action for knock-out whist", user_action);
                         break;
index 7c8d47f3de2415460b9f47b61945791d65726f88..97cae0b5e54751857a15939ceae32638ff8ac17a 100644 (file)
@@ -11,3 +11,24 @@ svg {
 .my-card:hover {
     transform: translateY(-20px);
 }
+
+.suit-glyph {
+    pointer-events: none;
+}
+
+.suit-highlight {
+    fill: transparent;
+    transition: fill 0.5s;
+}
+
+.suit-highlight:hover {
+    fill: #4040ff;
+}
+
+.suit-highlight.trumps {
+    fill: #2020ff;
+}
+
+.suit-highlight.trumps:hover {
+    fill: #4040ff;
+}
index 8331d57dfd52a02695f4cbfd635a7dc0b1d8cf6d..bcd7ed17129cc769f66b47f7faa5122b779fc27f 100644 (file)
@@ -49,14 +49,19 @@ impl Dealer {
             let from = self.dealer.game.actions_len();
             let actions = self.server.game_state(id, from).await?;
             for action in actions {
+                debug!("Taking action: {:?}", action);
                 if let Err(err) = self.dealer.game.take_action(action) {
                     error!("Action from database failed to apply: {}", err);
                 }
+                debug!("Dealer: Game state: {:#?}", self.dealer.game);
             }
             'take_action: loop {
                 match self.dealer.game.next_dealer_action() {
                     Some(action) => match self.take_action(action).await {
-                        Ok(ActionStatus::Committed) => continue 'take_action,
+                        Ok(ActionStatus::Committed) => {
+                            debug!("Dealer: Game state: {:#?}", self.dealer.game);
+                            continue 'take_action;
+                        }
                         Ok(ActionStatus::Interrupted) => continue 'retrieve_updates,
                         Err(err) => return Err(err),
                     },
index ff932a8b443e7f4b5ec36b344334b0c6879e1f32..f4e74008a39c3a9ebca67fd93ab6437a9f395ebc 100644 (file)
@@ -41,7 +41,7 @@ impl dyn Game {
     pub fn new(GameSummary{id, settings}: GameSummary) -> Box<Self> {
         match settings {
             GameSettings::Chatroom(settings) => Box::new(Chatroom::new(id, settings)),
-            _ => todo!()
+            GameSettings::KnockOutWhist(settings) => Box::new(KnockOutWhist::new(id, settings)),
         }
     }
 }
index 3ee5cfe4e1c41ad93eb5799948263082ee013dc6..4f6490c2db52974c2ab9fe44d7844718c26e4f9a 100644 (file)
@@ -82,6 +82,7 @@ impl KnockOutWhist {
     }
 
     fn trick_winner(&self) -> Option<Username> {
+        if self.trick.len() < self.seats.players_len() { return None; }
         let highest_trump = self.trick.iter()
             .filter(|(_, card)| Some(card.suit) == self.trumps)
             .max_by_key(|(_, card)| card.rank);
@@ -146,7 +147,7 @@ impl Game for KnockOutWhist {
                     Err(ActionError::OutOfTurn)
                 } else if !self.hand_contains_card(username, card) {
                     Err(ActionError::CardNotPlayable)
-                } else if self.led.is_some() && Some(card.suit) != self.led && self.hand_contains_suit(username, card.suit) {
+                } else if matches!(self.led, Some(led) if card.suit != led && self.hand_contains_suit(username, led)) {
                     Err(ActionError::CardNotPlayable)
                 } else {
                     Ok(ValidatedUserAction(UserAction{username, action: Action::PlayCard{card}}))
@@ -161,7 +162,7 @@ impl Game for KnockOutWhist {
     fn take_action(&mut self, ValidatedUserAction(UserAction{username, action}): ValidatedUserAction) -> Result<(), ActionError> {
         self.actions_len += 1;
         match (self.state, action) {
-            (_, Action::AddOn{..}) | (_, Action::RevealCard{..}) | (_, Action::Fold) | (_, Action::Bet{..}) => {
+            (_, Action::AddOn{..}) | (_, Action::Fold) | (_, Action::Bet{..}) => {
                 Err(ActionError::InvalidActionForGameType)
             }
             (KnockOutWhistState::NotStarted, Action::Join{seat, ..}) => {
@@ -170,7 +171,7 @@ impl Game for KnockOutWhist {
             (KnockOutWhistState::NotStarted, Action::Leave) => {
                 self.seats.remove_player(username)
             }
-            (KnockOutWhistState::NotStarted, Action::NextToDeal) => {
+            (_, Action::NextToDeal) => {
                 self.dealer = Some(username);
                 self.deck = FIFTY_TWO_CARD_DECK.iter().cloned().collect();
                 self.hands.clear();
@@ -195,7 +196,6 @@ impl Game for KnockOutWhist {
             (KnockOutWhistState::Dealing, Action::CommunityCard{card}) => {
                 self.trump_card = Some(card);
                 self.trumps = Some(card.suit);
-                self.state = KnockOutWhistState::Playing;
                 Ok(())
             }
             (KnockOutWhistState::Dealing, Action::EndDeal) => {
@@ -234,14 +234,13 @@ impl Game for KnockOutWhist {
             (KnockOutWhistState::Playing, Action::WinTrick) => {
                 *self.tricks_won.entry(username).or_default() += 1;
                 self.led = None;
-                self.trumps = None;
                 self.trick.clear();
                 if self.tricks_won.values().sum::<u32>() == self.cards_to_deal {
                     if let Some(&most_tricks_won) = self.tricks_won.values().max() {
                         self.winners = self.tricks_won.iter().filter(|&(_, &tricks)| tricks == most_tricks_won).map(|(&username, _)| username).collect();
                         self.cards_to_deal -= 1;
                         if self.winners.len() == 1 {
-                            self.call = self.winners.drain().next();
+                            self.call = self.winners.iter().next().cloned();
                         } else {
                             self.receiver = self.dealer;
                             while let Some(receiver) = self.receiver {
@@ -274,9 +273,7 @@ impl Game for KnockOutWhist {
             }
             (KnockOutWhistState::CutForCall, Action::RevealCard{card}) => {
                 self.deck.remove(&card);
-                if let Some(hand) = self.hands.get_mut(&username) {
-                    hand.insert(card);
-                }
+                self.hands.entry(username).or_default().insert(card);
                 if self.hands.values().map(HashSet::len).sum::<usize>() == self.winners.len() {
                     if let Some((username, max)) = self.cards_by_player().max_by_key(|(_, card)| card.rank) {
                         if self.cards_by_player().filter(|(_, card)| card.rank == max.rank).count() > 1 {
@@ -290,9 +287,8 @@ impl Game for KnockOutWhist {
                     }
                 }
                 while let Some(receiver) = self.receiver {
-                    if !self.winners.contains(&receiver) {
-                        self.receiver = self.seats.player_after(receiver);
-                    } else {
+                    self.receiver = self.seats.player_after(receiver);
+                    if matches!(self.receiver, Some(receiver) if self.winners.contains(&receiver)) {
                         break;
                     }
                 }
@@ -319,15 +315,15 @@ impl Game for KnockOutWhist {
                     let card = self.deck.iter().choose(&mut rng).cloned();
                     Some(ValidatedUserAction(UserAction{username, action: Action::ReceiveCard{card}}))
                 } else if let Some(username) = self.dealer {
-                    match self.call {
-                        None => {
+                    match (self.call, self.trump_card) {
+                        (None, None) => {
                             if let Some(&card) = self.deck.iter().choose(&mut rng) {
                                 Some(ValidatedUserAction(UserAction{username, action: Action::CommunityCard{card}}))
                             } else {
                                 None
                             }
                         }
-                        Some(_) => Some(ValidatedUserAction(UserAction{username, action: Action::EndDeal})),
+                        (Some(_), _) | (None, Some(_)) => Some(ValidatedUserAction(UserAction{username, action: Action::EndDeal})),
                     }
                 } else {
                     None
@@ -360,8 +356,11 @@ impl Game for KnockOutWhist {
             }
             KnockOutWhistState::CutForCall => {
                 if let Some(username) = self.receiver {
-                    let card = self.deck.iter().choose(&mut rng).cloned();
-                    Some(ValidatedUserAction(UserAction{username, action: Action::ReceiveCard{card}}))
+                    if let Some(card) = self.deck.iter().choose(&mut rng).cloned() {
+                        Some(ValidatedUserAction(UserAction{username, action: Action::RevealCard{card}}))
+                    } else {
+                        None
+                    }
                 } else if let Some(username) = self.dealer.and_then(|dealer| self.seats.player_after(dealer)) {
                     Some(ValidatedUserAction(UserAction{username, action: Action::NextToDeal}))
                 } else {