From bbe4d5e681ebe44113835e52b11b22b398d57b35 Mon Sep 17 00:00:00 2001 From: Geoffrey Allott Date: Tue, 23 Feb 2021 20:44:44 +0000 Subject: [PATCH] rework game as a trait --- Cargo.lock | 17 ++ Cargo.toml | 2 + site/img/card-2c.svg | 8 + site/img/card-2d.svg | 8 + site/img/card-2h.svg | 8 + site/img/card-2s.svg | 8 + site/img/card-3c.svg | 8 + site/img/card-3d.svg | 8 + site/img/card-3h.svg | 8 + site/img/card-3s.svg | 8 + site/img/card-4c.svg | 8 + site/img/card-4d.svg | 8 + site/img/card-4h.svg | 8 + site/img/card-4s.svg | 8 + site/img/card-5c.svg | 8 + site/img/card-5d.svg | 8 + site/img/card-5h.svg | 8 + site/img/card-5s.svg | 8 + site/img/card-6c.svg | 8 + site/img/card-6d.svg | 8 + site/img/card-6h.svg | 8 + site/img/card-6s.svg | 8 + site/img/card-7c.svg | 8 + site/img/card-7d.svg | 8 + site/img/card-7h.svg | 8 + site/img/card-7s.svg | 8 + site/img/card-8c.svg | 8 + site/img/card-8d.svg | 8 + site/img/card-8h.svg | 8 + site/img/card-8s.svg | 8 + site/img/card-9c.svg | 8 + site/img/card-9d.svg | 8 + site/img/card-9h.svg | 8 + site/img/card-9s.svg | 8 + site/img/card-Ac.svg | 8 + site/img/card-Ad.svg | 8 + site/img/card-Ah.svg | 8 + site/img/card-As.svg | 8 + site/img/card-Jc.svg | 8 + site/img/card-Jd.svg | 8 + site/img/card-Jh.svg | 8 + site/img/card-Js.svg | 8 + site/img/card-Kc.svg | 8 + site/img/card-Kd.svg | 8 + site/img/card-Kh.svg | 8 + site/img/card-Ks.svg | 8 + site/img/card-Qc.svg | 8 + site/img/card-Qd.svg | 8 + site/img/card-Qh.svg | 8 + site/img/card-Qs.svg | 8 + site/img/card-Tc.svg | 8 + site/img/card-Td.svg | 8 + site/img/card-Th.svg | 8 + site/img/card-Ts.svg | 8 + site/img/card-back-blue.svg | 11 ++ site/modules/card.js | 31 ++++ site/modules/socket.js | 181 +++++++++++++++++++++- site/style.css | 1 + site/style/whist.css | 13 ++ site/test.html | 20 +++ src/api.rs | 14 +- src/card.rs | 6 +- src/client.rs | 117 ++++++++------ src/dealer.rs | 89 +++++++++++ src/game.rs | 75 ++++++--- src/games/chatroom.rs | 92 +++++++++++ src/games/mod.rs | 57 +++++++ src/games/poker.rs | 79 ++++++++++ src/games/whist.rs | 36 +++++ src/gamestate.rs | 299 +++++++++++++++++++++++------------- src/hands.rs | 96 ++++++++++++ src/main.rs | 45 +++++- src/seats.rs | 17 +- src/server.rs | 43 +++--- src/username.rs | 109 +++++++++++++ 75 files changed, 1637 insertions(+), 229 deletions(-) create mode 100644 site/img/card-2c.svg create mode 100644 site/img/card-2d.svg create mode 100644 site/img/card-2h.svg create mode 100644 site/img/card-2s.svg create mode 100644 site/img/card-3c.svg create mode 100644 site/img/card-3d.svg create mode 100644 site/img/card-3h.svg create mode 100644 site/img/card-3s.svg create mode 100644 site/img/card-4c.svg create mode 100644 site/img/card-4d.svg create mode 100644 site/img/card-4h.svg create mode 100644 site/img/card-4s.svg create mode 100644 site/img/card-5c.svg create mode 100644 site/img/card-5d.svg create mode 100644 site/img/card-5h.svg create mode 100644 site/img/card-5s.svg create mode 100644 site/img/card-6c.svg create mode 100644 site/img/card-6d.svg create mode 100644 site/img/card-6h.svg create mode 100644 site/img/card-6s.svg create mode 100644 site/img/card-7c.svg create mode 100644 site/img/card-7d.svg create mode 100644 site/img/card-7h.svg create mode 100644 site/img/card-7s.svg create mode 100644 site/img/card-8c.svg create mode 100644 site/img/card-8d.svg create mode 100644 site/img/card-8h.svg create mode 100644 site/img/card-8s.svg create mode 100644 site/img/card-9c.svg create mode 100644 site/img/card-9d.svg create mode 100644 site/img/card-9h.svg create mode 100644 site/img/card-9s.svg create mode 100644 site/img/card-Ac.svg create mode 100644 site/img/card-Ad.svg create mode 100644 site/img/card-Ah.svg create mode 100644 site/img/card-As.svg create mode 100644 site/img/card-Jc.svg create mode 100644 site/img/card-Jd.svg create mode 100644 site/img/card-Jh.svg create mode 100644 site/img/card-Js.svg create mode 100644 site/img/card-Kc.svg create mode 100644 site/img/card-Kd.svg create mode 100644 site/img/card-Kh.svg create mode 100644 site/img/card-Ks.svg create mode 100644 site/img/card-Qc.svg create mode 100644 site/img/card-Qd.svg create mode 100644 site/img/card-Qh.svg create mode 100644 site/img/card-Qs.svg create mode 100644 site/img/card-Tc.svg create mode 100644 site/img/card-Td.svg create mode 100644 site/img/card-Th.svg create mode 100644 site/img/card-Ts.svg create mode 100644 site/img/card-back-blue.svg create mode 100644 site/modules/card.js create mode 100644 site/style/whist.css create mode 100644 site/test.html create mode 100644 src/dealer.rs create mode 100644 src/games/chatroom.rs create mode 100644 src/games/mod.rs create mode 100644 src/games/poker.rs create mode 100644 src/games/whist.rs create mode 100644 src/hands.rs create mode 100644 src/username.rs diff --git a/Cargo.lock b/Cargo.lock index a6cc0e8..e466e0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,6 +565,12 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "env_logger" version = "0.8.2" @@ -894,6 +900,15 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "itertools" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.7" @@ -1047,8 +1062,10 @@ dependencies = [ "async-std", "env_logger", "futures", + "itertools", "log", "rand 0.8.3", + "rand_chacha 0.3.0", "redis", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 521a4f8..bf8e9d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ async-std = { version = "1", features = ["attributes"] } env_logger = "0.8" log = "0.4" futures = "0.3" +itertools = "0.10" rand = "0.8" +rand_chacha = "0.3" redis = { version = "0.19", features = ["async-std-comp"] } serde = "1" serde_derive = "1" diff --git a/site/img/card-2c.svg b/site/img/card-2c.svg new file mode 100644 index 0000000..2afaf55 --- /dev/null +++ b/site/img/card-2c.svg @@ -0,0 +1,8 @@ + + + + 2 + ♣ + + + diff --git a/site/img/card-2d.svg b/site/img/card-2d.svg new file mode 100644 index 0000000..d781c5f --- /dev/null +++ b/site/img/card-2d.svg @@ -0,0 +1,8 @@ + + + + 2 + ♢ + + + diff --git a/site/img/card-2h.svg b/site/img/card-2h.svg new file mode 100644 index 0000000..2b5ae9d --- /dev/null +++ b/site/img/card-2h.svg @@ -0,0 +1,8 @@ + + + + 2 + ♡ + + + diff --git a/site/img/card-2s.svg b/site/img/card-2s.svg new file mode 100644 index 0000000..bf9d374 --- /dev/null +++ b/site/img/card-2s.svg @@ -0,0 +1,8 @@ + + + + 2 + ♠ + + + diff --git a/site/img/card-3c.svg b/site/img/card-3c.svg new file mode 100644 index 0000000..9cce91c --- /dev/null +++ b/site/img/card-3c.svg @@ -0,0 +1,8 @@ + + + + 3 + ♣ + + + diff --git a/site/img/card-3d.svg b/site/img/card-3d.svg new file mode 100644 index 0000000..87692b0 --- /dev/null +++ b/site/img/card-3d.svg @@ -0,0 +1,8 @@ + + + + 3 + ♢ + + + diff --git a/site/img/card-3h.svg b/site/img/card-3h.svg new file mode 100644 index 0000000..78a125f --- /dev/null +++ b/site/img/card-3h.svg @@ -0,0 +1,8 @@ + + + + 3 + ♡ + + + diff --git a/site/img/card-3s.svg b/site/img/card-3s.svg new file mode 100644 index 0000000..d80d75e --- /dev/null +++ b/site/img/card-3s.svg @@ -0,0 +1,8 @@ + + + + 3 + ♠ + + + diff --git a/site/img/card-4c.svg b/site/img/card-4c.svg new file mode 100644 index 0000000..c647371 --- /dev/null +++ b/site/img/card-4c.svg @@ -0,0 +1,8 @@ + + + + 4 + ♣ + + + diff --git a/site/img/card-4d.svg b/site/img/card-4d.svg new file mode 100644 index 0000000..f3d8128 --- /dev/null +++ b/site/img/card-4d.svg @@ -0,0 +1,8 @@ + + + + 4 + ♢ + + + diff --git a/site/img/card-4h.svg b/site/img/card-4h.svg new file mode 100644 index 0000000..cfaa1d0 --- /dev/null +++ b/site/img/card-4h.svg @@ -0,0 +1,8 @@ + + + + 4 + ♡ + + + diff --git a/site/img/card-4s.svg b/site/img/card-4s.svg new file mode 100644 index 0000000..4c9a7f4 --- /dev/null +++ b/site/img/card-4s.svg @@ -0,0 +1,8 @@ + + + + 4 + ♠ + + + diff --git a/site/img/card-5c.svg b/site/img/card-5c.svg new file mode 100644 index 0000000..d0822b1 --- /dev/null +++ b/site/img/card-5c.svg @@ -0,0 +1,8 @@ + + + + 5 + ♣ + + + diff --git a/site/img/card-5d.svg b/site/img/card-5d.svg new file mode 100644 index 0000000..c7d1d3f --- /dev/null +++ b/site/img/card-5d.svg @@ -0,0 +1,8 @@ + + + + 5 + ♢ + + + diff --git a/site/img/card-5h.svg b/site/img/card-5h.svg new file mode 100644 index 0000000..38c8da3 --- /dev/null +++ b/site/img/card-5h.svg @@ -0,0 +1,8 @@ + + + + 5 + ♡ + + + diff --git a/site/img/card-5s.svg b/site/img/card-5s.svg new file mode 100644 index 0000000..1bae79e --- /dev/null +++ b/site/img/card-5s.svg @@ -0,0 +1,8 @@ + + + + 5 + ♠ + + + diff --git a/site/img/card-6c.svg b/site/img/card-6c.svg new file mode 100644 index 0000000..458d98a --- /dev/null +++ b/site/img/card-6c.svg @@ -0,0 +1,8 @@ + + + + 6 + ♣ + + + diff --git a/site/img/card-6d.svg b/site/img/card-6d.svg new file mode 100644 index 0000000..d61f2fc --- /dev/null +++ b/site/img/card-6d.svg @@ -0,0 +1,8 @@ + + + + 6 + ♢ + + + diff --git a/site/img/card-6h.svg b/site/img/card-6h.svg new file mode 100644 index 0000000..eb27710 --- /dev/null +++ b/site/img/card-6h.svg @@ -0,0 +1,8 @@ + + + + 6 + ♡ + + + diff --git a/site/img/card-6s.svg b/site/img/card-6s.svg new file mode 100644 index 0000000..b7217ef --- /dev/null +++ b/site/img/card-6s.svg @@ -0,0 +1,8 @@ + + + + 6 + ♠ + + + diff --git a/site/img/card-7c.svg b/site/img/card-7c.svg new file mode 100644 index 0000000..a384abb --- /dev/null +++ b/site/img/card-7c.svg @@ -0,0 +1,8 @@ + + + + 7 + ♣ + + + diff --git a/site/img/card-7d.svg b/site/img/card-7d.svg new file mode 100644 index 0000000..fccc289 --- /dev/null +++ b/site/img/card-7d.svg @@ -0,0 +1,8 @@ + + + + 7 + ♢ + + + diff --git a/site/img/card-7h.svg b/site/img/card-7h.svg new file mode 100644 index 0000000..9218ab4 --- /dev/null +++ b/site/img/card-7h.svg @@ -0,0 +1,8 @@ + + + + 7 + ♡ + + + diff --git a/site/img/card-7s.svg b/site/img/card-7s.svg new file mode 100644 index 0000000..5771662 --- /dev/null +++ b/site/img/card-7s.svg @@ -0,0 +1,8 @@ + + + + 7 + ♠ + + + diff --git a/site/img/card-8c.svg b/site/img/card-8c.svg new file mode 100644 index 0000000..0fc2832 --- /dev/null +++ b/site/img/card-8c.svg @@ -0,0 +1,8 @@ + + + + 8 + ♣ + + + diff --git a/site/img/card-8d.svg b/site/img/card-8d.svg new file mode 100644 index 0000000..b91ddcd --- /dev/null +++ b/site/img/card-8d.svg @@ -0,0 +1,8 @@ + + + + 8 + ♢ + + + diff --git a/site/img/card-8h.svg b/site/img/card-8h.svg new file mode 100644 index 0000000..44a54b3 --- /dev/null +++ b/site/img/card-8h.svg @@ -0,0 +1,8 @@ + + + + 8 + ♡ + + + diff --git a/site/img/card-8s.svg b/site/img/card-8s.svg new file mode 100644 index 0000000..2a65b28 --- /dev/null +++ b/site/img/card-8s.svg @@ -0,0 +1,8 @@ + + + + 8 + ♠ + + + diff --git a/site/img/card-9c.svg b/site/img/card-9c.svg new file mode 100644 index 0000000..a7f9706 --- /dev/null +++ b/site/img/card-9c.svg @@ -0,0 +1,8 @@ + + + + 9 + ♣ + + + diff --git a/site/img/card-9d.svg b/site/img/card-9d.svg new file mode 100644 index 0000000..8c86ba2 --- /dev/null +++ b/site/img/card-9d.svg @@ -0,0 +1,8 @@ + + + + 9 + ♢ + + + diff --git a/site/img/card-9h.svg b/site/img/card-9h.svg new file mode 100644 index 0000000..bd760ad --- /dev/null +++ b/site/img/card-9h.svg @@ -0,0 +1,8 @@ + + + + 9 + ♡ + + + diff --git a/site/img/card-9s.svg b/site/img/card-9s.svg new file mode 100644 index 0000000..1ad161e --- /dev/null +++ b/site/img/card-9s.svg @@ -0,0 +1,8 @@ + + + + 9 + ♠ + + + diff --git a/site/img/card-Ac.svg b/site/img/card-Ac.svg new file mode 100644 index 0000000..8f8178d --- /dev/null +++ b/site/img/card-Ac.svg @@ -0,0 +1,8 @@ + + + + A + ♣ + + + diff --git a/site/img/card-Ad.svg b/site/img/card-Ad.svg new file mode 100644 index 0000000..5547b38 --- /dev/null +++ b/site/img/card-Ad.svg @@ -0,0 +1,8 @@ + + + + A + ♢ + + + diff --git a/site/img/card-Ah.svg b/site/img/card-Ah.svg new file mode 100644 index 0000000..4251cac --- /dev/null +++ b/site/img/card-Ah.svg @@ -0,0 +1,8 @@ + + + + A + ♡ + + + diff --git a/site/img/card-As.svg b/site/img/card-As.svg new file mode 100644 index 0000000..e5ab9af --- /dev/null +++ b/site/img/card-As.svg @@ -0,0 +1,8 @@ + + + + A + ♠ + + + diff --git a/site/img/card-Jc.svg b/site/img/card-Jc.svg new file mode 100644 index 0000000..eb1e194 --- /dev/null +++ b/site/img/card-Jc.svg @@ -0,0 +1,8 @@ + + + + J + ♣ + + + diff --git a/site/img/card-Jd.svg b/site/img/card-Jd.svg new file mode 100644 index 0000000..d6e5271 --- /dev/null +++ b/site/img/card-Jd.svg @@ -0,0 +1,8 @@ + + + + J + ♢ + + + diff --git a/site/img/card-Jh.svg b/site/img/card-Jh.svg new file mode 100644 index 0000000..609ab30 --- /dev/null +++ b/site/img/card-Jh.svg @@ -0,0 +1,8 @@ + + + + J + ♡ + + + diff --git a/site/img/card-Js.svg b/site/img/card-Js.svg new file mode 100644 index 0000000..d61fda4 --- /dev/null +++ b/site/img/card-Js.svg @@ -0,0 +1,8 @@ + + + + J + ♠ + + + diff --git a/site/img/card-Kc.svg b/site/img/card-Kc.svg new file mode 100644 index 0000000..13f455c --- /dev/null +++ b/site/img/card-Kc.svg @@ -0,0 +1,8 @@ + + + + K + ♣ + + + diff --git a/site/img/card-Kd.svg b/site/img/card-Kd.svg new file mode 100644 index 0000000..1edcfc2 --- /dev/null +++ b/site/img/card-Kd.svg @@ -0,0 +1,8 @@ + + + + K + ♢ + + + diff --git a/site/img/card-Kh.svg b/site/img/card-Kh.svg new file mode 100644 index 0000000..9c70f65 --- /dev/null +++ b/site/img/card-Kh.svg @@ -0,0 +1,8 @@ + + + + K + ♡ + + + diff --git a/site/img/card-Ks.svg b/site/img/card-Ks.svg new file mode 100644 index 0000000..03708c6 --- /dev/null +++ b/site/img/card-Ks.svg @@ -0,0 +1,8 @@ + + + + K + ♠ + + + diff --git a/site/img/card-Qc.svg b/site/img/card-Qc.svg new file mode 100644 index 0000000..b08cabd --- /dev/null +++ b/site/img/card-Qc.svg @@ -0,0 +1,8 @@ + + + + Q + ♣ + + + diff --git a/site/img/card-Qd.svg b/site/img/card-Qd.svg new file mode 100644 index 0000000..f095977 --- /dev/null +++ b/site/img/card-Qd.svg @@ -0,0 +1,8 @@ + + + + Q + ♢ + + + diff --git a/site/img/card-Qh.svg b/site/img/card-Qh.svg new file mode 100644 index 0000000..a57a540 --- /dev/null +++ b/site/img/card-Qh.svg @@ -0,0 +1,8 @@ + + + + Q + ♡ + + + diff --git a/site/img/card-Qs.svg b/site/img/card-Qs.svg new file mode 100644 index 0000000..edc4c07 --- /dev/null +++ b/site/img/card-Qs.svg @@ -0,0 +1,8 @@ + + + + Q + ♠ + + + diff --git a/site/img/card-Tc.svg b/site/img/card-Tc.svg new file mode 100644 index 0000000..0236727 --- /dev/null +++ b/site/img/card-Tc.svg @@ -0,0 +1,8 @@ + + + + 10 + ♣ + + + diff --git a/site/img/card-Td.svg b/site/img/card-Td.svg new file mode 100644 index 0000000..7c492b0 --- /dev/null +++ b/site/img/card-Td.svg @@ -0,0 +1,8 @@ + + + + 10 + ♢ + + + diff --git a/site/img/card-Th.svg b/site/img/card-Th.svg new file mode 100644 index 0000000..3e40f9f --- /dev/null +++ b/site/img/card-Th.svg @@ -0,0 +1,8 @@ + + + + 10 + ♡ + + + diff --git a/site/img/card-Ts.svg b/site/img/card-Ts.svg new file mode 100644 index 0000000..0bad8c7 --- /dev/null +++ b/site/img/card-Ts.svg @@ -0,0 +1,8 @@ + + + + 10 + ♠ + + + diff --git a/site/img/card-back-blue.svg b/site/img/card-back-blue.svg new file mode 100644 index 0000000..bef58cd --- /dev/null +++ b/site/img/card-back-blue.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/site/modules/card.js b/site/modules/card.js new file mode 100644 index 0000000..2422de5 --- /dev/null +++ b/site/modules/card.js @@ -0,0 +1,31 @@ +export function short_rank(rank) { + switch (rank) { + case "Ace": return "A"; + case "Two": return "2"; + case "Three": return "3"; + case "Four": return "4"; + case "Five": return "5"; + case "Six": return "6"; + case "Seven": return "7"; + case "Eight": return "8"; + case "Nine": return "9"; + case "Ten": return "T"; + case "Jack": return "J"; + case "Queen": return "Q"; + case "King": return "K"; + } +} + +export function short_suit(suit) { + switch (suit) { + case "Clubs": return "c"; + case "Diamonds": return "d"; + case "Hearts": return "h"; + case "Spades": return "s"; + } +} + +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"; +} diff --git a/site/modules/socket.js b/site/modules/socket.js index 526a4ee..2a50510 100644 --- a/site/modules/socket.js +++ b/site/modules/socket.js @@ -1,3 +1,7 @@ +const svgns = "http://www.w3.org/2000/svg"; + +import { card_href } from "./card.js"; + export class Socket { constructor(container, login_all_sockets) { this.container = container; @@ -75,8 +79,12 @@ export class Socket { delete this.action; break; case "TakeActionFailure": - console.error("Taking action failed: " + message.reason, this.action); - delete this.action; + if (message.reason === "SeatNotAvailable") { + this.take_action({action: "Join", seat: this.action.seat + 1, chips: this.action.chips}); + } else { + console.error("Taking action failed: " + message.reason, this.action); + delete this.action; + } break; case "NewAction": this.add_action(message.action); @@ -227,6 +235,39 @@ export class Socket { this.redraw_chatroom(); } + create_knock_out_whist() { + this.container.textContent = ""; + + this.svg = document.createElementNS(svgns, "svg"); + this.svg.classList.add("knock-out-whist"); + this.svg.setAttribute("viewBox", "0 0 500 500"); + + const background = document.createElementNS(svgns, "rect"); + background.setAttribute("width", "500"); + background.setAttribute("height", "500"); + background.setAttribute("fill", "#404040"); + this.svg.append(background); + + const table = document.createElementNS(svgns, "ellipse"); + table.setAttribute("cx", "250"); + table.setAttribute("cy", "253"); + table.setAttribute("rx", "250"); + table.setAttribute("ry", "110"); + table.setAttribute("fill", "#604010"); + this.svg.append(table); + + const felt = document.createElementNS(svgns, "ellipse"); + felt.setAttribute("cx", "250"); + felt.setAttribute("cy", "250"); + felt.setAttribute("rx", "240"); + felt.setAttribute("ry", "100"); + felt.setAttribute("fill", "green"); + this.svg.append(felt); + + this.container.append(this.svg); + this.redraw_knock_out_whist(); + } + join_element(username) { const join_element = document.createElement("div"); join_element.classList.add("chatroom-join"); @@ -269,20 +310,64 @@ export class Socket { return leave_element; } + set_card_positions() { + const seats = Math.max(...this.game.seats.values()) + 1; + const my_seat = this.game.seats.get(this.auth.username); + for (const [username, cards] of this.game.hands) { + const seat = this.game.seats.get(username); + const angle = ((seat - my_seat) % seats) * 2 * Math.PI / seats; + const offset = cards.length * 10; + let x = 227.5 + offset + 240 * Math.sin(angle); + const y = 210 + 120 * Math.cos(angle); + for (const {card, image} of cards) { + image.classList.toggle("my-card", username === this.auth.username); + image.setAttribute("x", x); + image.setAttribute("y", y); + x -= 20; + } + } + for (const {card, image} of this.game.trick) { + const x = 227.5; + const y = 210; + image.classList.remove("my-card"); + image.setAttribute("x", x); + image.setAttribute("y", y); + } + for (const {card, image} of this.game.community) { + const x = 120.0; + const y = 210; + image.setAttribute("x", x); + image.setAttribute("y", y); + } + } + + card_image(username, card) { + const image = document.createElementNS(svgns, "image"); + image.setAttribute("width", "45"); + image.setAttribute("height", "70"); + image.setAttribute("href", card_href(card)); + if (username === this.auth.username) { + image.onclick = () => this.take_action({action: "PlayCard", card: card}); + } + this.svg.append(image); + return image; + } + add_action(user_action, initialising) { switch (this.game.summary.settings.format) { case "Chatroom": const is_at_end = this.chatroom_chat.scrollTop + this.chatroom_chat.clientHeight == this.chatroom_chat.scrollHeight; switch (user_action.action.action) { case "Join": - this.game.users.add(user_action.username); + this.game.seats.set(user_action.username, user_action.action.seat); this.chatroom_chat.append(this.join_element(user_action.username)); break; case "Message": this.chatroom_chat.append(this.message_element(user_action.username, user_action.action.message)); break; case "Leave": - this.game.users.delete(user_action.username); + this.game.seats.delete(user_action.username); + this.game.hands.delete(user_action.username); this.chatroom_chat.append(this.leave_element(user_action.username)); if (!initialising && user_action.username === this.auth.username) { this.send({type: "JoinLobby", filter: ""}); @@ -299,6 +384,74 @@ export class Socket { }); } break; + case "KnockOutWhist": + switch (user_action.action.action) { + case "Join": + this.game.seats.set(user_action.username, user_action.action.seat); + this.game.hands.set(user_action.username, []); + break; + case "Leave": + case "KnockedOut": + this.game.seats.delete(user_action.username); + this.game.hands.delete(user_action.username); + break; + case "NextToDeal": + this.dealer = user_action.username; + break; + case "ReceiveCard": + const card = { + card: user_action.action.card, + image: this.card_image(user_action.username, user_action.action.card), + }; + this.game.hands.get(user_action.username).push(card); + this.set_card_positions(); + break; + case "PlayCard": + let removed = false; + const cards = this.game.hands.get(user_action.username).filter((card) => { + if (card.card === null) { + if (!removed) { + this.svg.removeChild(card.image); + this.game.trick.push({card: user_action.action.card, image: this.card_image(user_action.username, user_action.action.card)}); + removed = true; + return false; + } else { + return true; + } + } else if (card.card.suit === user_action.action.card.suit && card.card.rank === user_action.action.card.rank) { + this.game.trick.push(card); + return false; + } else { + return true; + } + }); + this.game.hands.set(user_action.username, cards); + this.set_card_positions(); + case "ChooseTrumps": + this.game.trump_suit = user_action.action.suit; + break; + case "CommunityCard": + const community_card = { + card: user_action.action.card, + image: this.card_image(null, user_action.action.card), + }; + this.game.community.push(community_card); + this.game.trump_suit = user_action.action.card.suit; + break; + case "WinTrick": + for (const card of this.game.trick) { + this.svg.removeChild(card.image); + } + this.game.trick = []; + break; + case "EndDeal": + console.log(this); + break; + default: + console.error("Unhandled action for knock-out whist", user_action); + break; + } + break; } } @@ -309,13 +462,29 @@ export class Socket { } } + redraw_knock_out_whist() { + for (const user_action of this.game.state.actions) { + this.add_action(user_action, true); + } + } + create_game_display() { this.container.textContent = ""; switch (this.game.summary.settings.format) { case "Chatroom": - this.game.users = new Set(); + this.game.seats = new Map(); this.create_chatroom(); - if (!this.game.users.has(this.auth.username)) { + if (!this.game.seats.has(this.auth.username)) { + this.take_action({action: "Join", seat: 0, chips: 0}); + } + break; + case "KnockOutWhist": + this.game.seats = new Map(); + this.game.hands = new Map(); + this.game.community = []; + this.game.trick = []; + this.create_knock_out_whist(); + if (!this.game.seats.has(this.auth.username)) { this.take_action({action: "Join", seat: 0, chips: 0}); } break; diff --git a/site/style.css b/site/style.css index 1528a1d..5212560 100644 --- a/site/style.css +++ b/site/style.css @@ -1,6 +1,7 @@ @import url("style/login.css"); @import url("style/game-list.css"); @import url("style/chatroom.css"); +@import url("style/whist.css"); html, body { width: 100%; diff --git a/site/style/whist.css b/site/style/whist.css new file mode 100644 index 0000000..7c8d47f --- /dev/null +++ b/site/style/whist.css @@ -0,0 +1,13 @@ +svg { + width: 100%; + height: 100%; +} + +.my-card { + transform: none; + transition: transform 0.5s; +} + +.my-card:hover { + transform: translateY(-20px); +} diff --git a/site/test.html b/site/test.html new file mode 100644 index 0000000..aea30b4 --- /dev/null +++ b/site/test.html @@ -0,0 +1,20 @@ + + + + + + + +
+ + + + + + + + + +
+ + diff --git a/src/api.rs b/src/api.rs index c4f464c..6080421 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,5 +1,7 @@ use crate::auth::Auth; -use crate::game::{Action, Game, GameSettings, GameSummary, UserAction}; +use crate::game::{Action, GameSettings, GameSummary}; +use crate::games::UserAction; +use crate::username::Username; #[derive(Debug, Clone, Deserialize)] pub enum Scope { @@ -12,8 +14,8 @@ pub enum Scope { #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type")] pub enum ClientMessage { - CreateUser { username: String, auth: Auth, nickname: String }, - Login { username: String }, + CreateUser { username: Username, auth: Auth, nickname: String }, + Login { username: Username }, LoginAuthResponse { signature: String }, ChangeAuth { auth: Auth }, ChangeNickname { nickname: String }, @@ -46,16 +48,16 @@ pub enum ServerMessage { JoinLobbySuccess { games: Vec }, JoinLobbyFailure { reason: String }, NewGame { game: GameSummary }, - JoinGameSuccess { game: Game }, + JoinGameSuccess { summary: GameSummary, actions: Vec }, JoinGameFailure { reason: String }, NewAction { action: UserAction }, TakeActionSuccess, TakeActionFailure { reason: String }, - NewMessage { username: String, message: String }, + NewMessage { username: Username, message: String }, LeaveGameSuccess, LeaveGameFailure { reason: String }, LeaveLobbySuccess, LeaveLobbyFailure { reason: String }, - HandHistory { games: Vec }, + HandHistory { games: Vec<(GameSummary, Vec)> }, // TODO ProtocolError { reason: String }, } diff --git a/src/card.rs b/src/card.rs index f7d061e..aa09f78 100644 --- a/src/card.rs +++ b/src/card.rs @@ -3,7 +3,7 @@ use std::fmt::{self, Display, Formatter}; use self::Rank::*; use self::Suit::*; -#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] pub enum Rank { Two = 2, Three = 3, @@ -40,7 +40,7 @@ impl Display for Rank { } } -#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] pub enum Suit { Clubs, Diamonds, @@ -59,7 +59,7 @@ impl Display for Suit { } } -#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)] pub struct Card { pub rank: Rank, pub suit: Suit, diff --git a/src/client.rs b/src/client.rs index 9fb6a24..45a2be0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,8 +3,10 @@ use std::collections::HashSet; use futures::stream::{Stream, StreamExt, empty, iter, once}; use crate::api::{ClientMessage, ServerMessage}; -use crate::game::{Game, GameList, UserAction}; +use crate::game::GameList; +use crate::games::{Game, UserAction}; use crate::server::{ActionStatus, ServerState}; +use crate::username::Username; pub struct ConnectionState { server: ServerState, @@ -14,9 +16,9 @@ pub struct ConnectionState { #[derive(Debug, Clone)] pub enum ClientState { Connected, - LoginAuthIssued { username: String, challenge: String }, + LoginAuthIssued { username: Username, challenge: String }, LoggedIn { - username: String, + username: Username, state: LoggedInState, }, } @@ -25,14 +27,14 @@ pub enum ClientState { pub enum ClientInterest { GameList, Game { id: u32 }, - User { username: String }, + User { username: Username }, } #[derive(Debug, Clone)] pub enum LoggedInState { Idle, InLobby { game_list: GameList }, - InGame { game: Game }, + InGame { game: Box }, } impl ConnectionState { @@ -56,10 +58,17 @@ impl ConnectionState { _ => empty().boxed(), } ClientInterest::Game{id} => match &mut self.client { - ClientState::LoggedIn{ref username, state: LoggedInState::InGame{ref mut game}} if game.id() == id => { + &mut ClientState::LoggedIn{username, state: LoggedInState::InGame{ref mut game}} if game.id() == id => { + let id = game.id(); let from = game.actions_len(); - match self.server.update_game_state(game).await { - Ok(()) => iter(game.update_view_for(username, from)).map(|action| ServerMessage::NewAction{action}).boxed(), + match self.server.game_state(id, from).await { + Ok(actions) => { + let actions_view: Vec<_> = actions.iter().map(|action| action.view_for(username)).collect(); + for action in actions { + game.take_action(action); + } + iter(actions_view).map(|action| ServerMessage::NewAction{action}).boxed() + } Err(err) => once(async move { ServerMessage::ProtocolError{reason: err.to_string()} }).boxed(), } } @@ -71,8 +80,7 @@ impl ConnectionState { pub fn interests(&self) -> HashSet { let mut ret = HashSet::new(); - if let ClientState::LoggedIn{ref username, ref state} = &self.client { - let username = username.to_string(); + if let &ClientState::LoggedIn{username, ref state} = &self.client { ret.insert(ClientInterest::User{username}); match state { LoggedInState::Idle => {}, @@ -82,7 +90,6 @@ impl ConnectionState { LoggedInState::InGame{ref game} => { ret.insert(ClientInterest::Game{id: game.id()}); for username in game.players() { - let username = username.to_string(); ret.insert(ClientInterest::User{username}); } } @@ -105,18 +112,18 @@ impl ConnectionState { async fn message_response(&mut self, message: ClientMessage) -> ServerMessage { match (&mut self.client, message) { (_, ClientMessage::CreateUser{username, auth, nickname}) => { - match self.server.create_user(&username, auth, &nickname).await { + match self.server.create_user(username, auth, &nickname).await { Ok(()) => ServerMessage::CreateUserSuccess, Err(_) => ServerMessage::CreateUserFailure{reason: "User already exists".to_string()}, } } - (ClientState::Connected, ClientMessage::Login{username}) => { + (&mut ClientState::Connected, ClientMessage::Login{username}) => { let challenge = format!("{:032x}{:032x}", rand::random::(), rand::random::()); self.client = ClientState::LoginAuthIssued{username, challenge: challenge.clone()}; ServerMessage::LoginAuthChallenge{challenge} } - (ClientState::LoginAuthIssued{username, challenge}, ClientMessage::LoginAuthResponse{signature}) => { - if self.server.verify(&username, &challenge, &signature).await { + (&mut ClientState::LoginAuthIssued{username, ref challenge}, ClientMessage::LoginAuthResponse{signature}) => { + if self.server.verify(username, &challenge, &signature).await { self.client = ClientState::LoggedIn{username: username.clone(), state: LoggedInState::Idle}; ServerMessage::LoginSuccess } else { @@ -124,7 +131,7 @@ impl ConnectionState { ServerMessage::LoginFailure{reason: "Invalid username or password".to_string()} } } - (ClientState::LoggedIn{username, ..}, ClientMessage::JoinLobby{filter}) => { + (&mut ClientState::LoggedIn{username, ..}, ClientMessage::JoinLobby{filter}) => { let mut game_list = GameList::new(filter); match self.server.update_game_list(&mut game_list).await { Ok(()) => { @@ -135,67 +142,79 @@ impl ConnectionState { Err(err) => ServerMessage::JoinLobbyFailure{reason: err.to_string()}, } } - (ClientState::LoggedIn{..}, ClientMessage::CreateGame{settings}) => { + (&mut ClientState::LoggedIn{..}, ClientMessage::CreateGame{settings}) => { match self.server.create_game(settings).await { Ok(id) => ServerMessage::CreateGameSuccess{id}, Err(err) => ServerMessage::CreateGameFailure{reason: err.to_string()}, } } - (ClientState::LoggedIn{username, ..}, ClientMessage::JoinGame{id}) => { - match self.server.get_game(id).await { - Ok(game) => { - let game_view = game.view_for(&username); + (&mut ClientState::LoggedIn{username, ..}, ClientMessage::JoinGame{id}) => { + match (self.server.game_summary(id).await, self.server.game_state(id, 0).await) { + (Ok(summary), Ok(actions)) => { + let actions_view = actions.iter().map(|action| action.view_for(username)).collect(); + let mut game = Game::new(summary.clone()); + for action in actions { + if let Err(err) = game.take_action(action) { + error!("Action from database failed to apply: {}", err); + } + } self.client = ClientState::LoggedIn{username: username.clone(), state: LoggedInState::InGame{game}}; - ServerMessage::JoinGameSuccess{game: game_view} + ServerMessage::JoinGameSuccess{summary, actions: actions_view} } - Err(err) => ServerMessage::JoinGameFailure{reason: err.to_string()}, + (Err(err), _) => ServerMessage::JoinGameFailure{reason: err.to_string()}, + (_, Err(err)) => ServerMessage::JoinGameFailure{reason: err.to_string()}, } } - (ClientState::LoggedIn{ref username, state: LoggedInState::InGame{ref mut game}}, ClientMessage::TakeAction{action}) => { - if action.is_initiated_server_side_only() { - return ServerMessage::TakeActionFailure{reason: "Not authorised".to_string()}; - } - let action = UserAction{username: username.clone(), action}; + (&mut ClientState::LoggedIn{username, state: LoggedInState::InGame{ref mut game}}, ClientMessage::TakeAction{action}) => { + let action = UserAction{username, action}; + let id = game.id(); loop { let len = game.actions_len(); - if let Err(err) = game.verify(&action) { - return ServerMessage::TakeActionFailure{reason: err.to_string()}; - } - match self.server.take_action(game.id(), len, &action).await { - Ok(ActionStatus::Committed) => match game.take_action(action) { - Ok(()) => return ServerMessage::TakeActionSuccess, - Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, - } - Ok(ActionStatus::Interrupted) => { - debug!("Action {:?} was interrupted - updating game state", action); - if let Err(err) = self.server.update_game_state(game).await { - return ServerMessage::TakeActionFailure{reason: err.to_string()}; + match game.validate_action(action.clone()) { + Ok(action) => match self.server.take_action(id, len, &action).await { + Ok(ActionStatus::Committed) => match game.take_action(action) { + Ok(()) => return ServerMessage::TakeActionSuccess, + Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, } + Ok(ActionStatus::Interrupted) => { + debug!("Action {:?} was interrupted - updating game state", action); + match self.server.game_state(id, len).await { + Ok(actions) => for action in actions { + if let Err(err) = game.take_action(action) { + return ServerMessage::TakeActionFailure{reason: err.to_string()}; + } + } + Err(err) => { + return ServerMessage::TakeActionFailure{reason: err.to_string()}; + } + } + } + Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, } Err(err) => return ServerMessage::TakeActionFailure{reason: err.to_string()}, } } } - (ClientState::LoggedIn{username, state: LoggedInState::InLobby{..}}, ClientMessage::LeaveLobby) => { - self.client = ClientState::LoggedIn{username: username.clone(), state: LoggedInState::Idle}; + (&mut ClientState::LoggedIn{username, state: LoggedInState::InLobby{..}}, ClientMessage::LeaveLobby) => { + self.client = ClientState::LoggedIn{username, state: LoggedInState::Idle}; ServerMessage::LeaveGameSuccess } - (ClientState::LoggedIn{username, state: LoggedInState::InGame{..}}, ClientMessage::LeaveGame) => { - self.client = ClientState::LoggedIn{username: username.clone(), state: LoggedInState::Idle}; + (&mut ClientState::LoggedIn{username, state: LoggedInState::InGame{..}}, ClientMessage::LeaveGame) => { + self.client = ClientState::LoggedIn{username, state: LoggedInState::Idle}; ServerMessage::LeaveGameSuccess } - (ClientState::LoggedIn{..}, ClientMessage::Logout) => { + (&mut ClientState::LoggedIn{..}, ClientMessage::Logout) => { self.client = ClientState::Connected; ServerMessage::LogoutSuccess } - (ClientState::LoggedIn{username, ..}, ClientMessage::ChangeAuth{auth}) => { - match self.server.set_user_auth(&username, auth).await { + (&mut ClientState::LoggedIn{username, ..}, ClientMessage::ChangeAuth{auth}) => { + match self.server.set_user_auth(username, auth).await { Ok(()) => ServerMessage::ChangeAuthSuccess, Err(err) => ServerMessage::ChangeAuthFailure{reason: err.to_string()}, } } - (ClientState::LoggedIn{username, ..}, ClientMessage::ChangeNickname{nickname}) => { - match self.server.set_user_nickname(&username, &nickname).await { + (&mut ClientState::LoggedIn{username, ..}, ClientMessage::ChangeNickname{nickname}) => { + match self.server.set_user_nickname(username, &nickname).await { Ok(()) => ServerMessage::ChangeNicknameSuccess, Err(err) => ServerMessage::ChangeNicknameFailure{reason: err.to_string()}, } diff --git a/src/dealer.rs b/src/dealer.rs new file mode 100644 index 0000000..3a2ebc1 --- /dev/null +++ b/src/dealer.rs @@ -0,0 +1,89 @@ +use std::collections::{HashMap, HashSet}; + +use async_std::stream::StreamExt; +use futures::channel::mpsc::Receiver; +use rand::prelude::*; +use redis::{ErrorKind, RedisError, RedisResult}; + +use crate::client::ClientInterest; +use crate::game::Action; +use crate::games::{Game, ValidatedUserAction}; +use crate::server::{ActionStatus, ServerState}; + +pub struct Dealer { + server: ServerState, + dealer: DealerState, +} + +#[derive(Debug, Clone)] +pub struct DealerState { + game: Box, +} + +impl Dealer { + pub async fn new(mut server: ServerState, id: u32) -> RedisResult { + let mut interests = HashSet::new(); + interests.insert(ClientInterest::Game{id}); + server.register_interests(interests).await; + let summary = server.game_summary(id).await?; + let actions = server.game_state(id, 0).await?; + let mut game = Game::new(summary); + for action in actions { + if let Err(err) = game.take_action(action) { + error!("Action from database failed to apply: {}", err); + } + } + let mut dealer = Dealer{server, dealer: DealerState{game}}; + dealer.retrieve_updates().await?; + Ok(dealer) + } + + pub async fn start(mut self, mut update_stream: Receiver) { + while let Some(_) = update_stream.next().await { + match self.retrieve_updates().await { + Ok(()) => continue, + Err(err) => { + error!("Could not retrieve updates: {}", err); + break; + } + } + } + } + + pub async fn retrieve_updates(&mut self) -> RedisResult<()> { + let id = self.dealer.game.id(); + 'retrieve_updates: loop { + let from = self.dealer.game.actions_len(); + let actions = self.server.game_state(id, from).await?; + for action in actions { + if let Err(err) = self.dealer.game.take_action(action) { + error!("Action from database failed to apply: {}", err); + } + } + '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::Interrupted) => continue 'retrieve_updates, + Err(err) => return Err(err), + }, + None => return Ok(()), + } + } + } + } + + async fn take_action(&mut self, action: ValidatedUserAction) -> RedisResult { + let game = &mut self.dealer.game; + match self.server.take_action(game.id(), game.actions_len(), &action).await? { + ActionStatus::Committed => match game.take_action(action) { + Ok(()) => return Ok(ActionStatus::Committed), + Err(err) => return Err(RedisError::from((ErrorKind::ClientError, "Invalid action", err.to_string()))), + } + ActionStatus::Interrupted => { + debug!("Action {:?} was interrupted", action); + Ok(ActionStatus::Interrupted) + } + } + } +} diff --git a/src/game.rs b/src/game.rs index 3e93b9e..52452fe 100644 --- a/src/game.rs +++ b/src/game.rs @@ -64,6 +64,7 @@ pub enum Action { WinGame, Message { message: String }, Leave, + KnockedOut, } impl Action { @@ -73,15 +74,6 @@ impl Action { action => action.clone(), } } - - pub fn is_initiated_server_side_only(&self) -> bool { - match self { - Action::Join{..} | Action::AddOn{..} | Action::RevealCard{..} | Action::PlayCard{..} | - Action::ChooseTrumps{..} | Action::Fold | Action::Bet{..} | Action::Message{..} | Action::Leave => false, - Action::NextToDeal | Action::CommunityCard{..} | Action::ReceiveCard{..} | Action::EndDeal | - Action::WinTrick | Action::WinHand{..} | Action::WinGame => true, - } - } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -104,6 +96,7 @@ pub enum ActionError { NotAuthorised, AlreadyJoined, GameHasStarted, + SeatNotAvailable, NoSeatAvailable, OutOfTurn, CardNotPlayable, @@ -118,6 +111,7 @@ impl Display for ActionError { ActionError::NotAuthorised => f.write_str("NotAuthorised"), ActionError::AlreadyJoined => f.write_str("AlreadyJoined"), ActionError::GameHasStarted => f.write_str("GameHasStarted"), + ActionError::SeatNotAvailable => f.write_str("SeatNotAvailable"), ActionError::NoSeatAvailable => f.write_str("NoSeatAvailable"), ActionError::OutOfTurn => f.write_str("OutOfTurn"), ActionError::CardNotPlayable => f.write_str("CardNotPlayable"), @@ -136,6 +130,10 @@ impl GameSummary { pub fn new(id: u32, settings: GameSettings) -> Self { Self{id, settings} } + + pub fn id(&self) -> u32 { + self.id + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -164,6 +162,34 @@ impl Game { self.state.players() } + pub fn players_len(&self) -> u32 { + self.state.players_len() + } + + pub fn deck(&self) -> HashSet { + self.state.deck() + } + + pub fn has_started(&self) -> bool { + self.state.has_started() + } + + pub fn dealing_to(&self) -> Option { + self.state.dealing_to() + } + + pub fn dealer(&self) -> Option { + self.state.dealer() + } + + pub fn all_players_have_played_a_card(&self) -> bool { + self.state.all_players_have_played_a_card() + } + + pub fn trick_winner(&self) -> Option { + self.state.trick_winner() + } + pub fn view_for(&self, username: &str) -> Game { Game { summary: self.summary.clone(), @@ -179,34 +205,37 @@ impl Game { debug!("Verifying action: UserAction {{ username: {:?}, action: {:?} }}", username, action); match self.summary.settings { GameSettings::Chatroom{max_players, ..} => match action { - Action::Join{seat: 0, chips: 0} if self.state.user_has_joined(username) => Err(ActionError::AlreadyJoined), - Action::Join{seat: 0, chips: 0} if self.state.num_players() + 1 > max_players => Err(ActionError::NoSeatAvailable), - Action::Join{seat: 0, chips: 0} => Ok(()), - Action::Message{..} if self.state.user_has_joined(username) => Ok(()), + Action::Join{chips: 0, ..} if self.state.player_has_joined(username) => Err(ActionError::AlreadyJoined), + Action::Join{chips: 0, ..} if self.state.players_len() + 1 > max_players => Err(ActionError::NoSeatAvailable), + Action::Join{seat, chips: 0} if !self.state.seat_is_available(*seat) => Err(ActionError::SeatNotAvailable), + Action::Join{chips: 0, ..} => Ok(()), + Action::Message{..} if self.state.player_has_joined(username) => Ok(()), Action::Message{..} => Err(ActionError::NotAuthorised), - Action::Leave if self.state.user_has_joined(username) => Ok(()), + Action::Leave if self.state.player_has_joined(username) => Ok(()), Action::Leave => Err(ActionError::NotAuthorised), _ => Err(ActionError::InvalidActionForGameType), }, GameSettings::KnockOutWhist{max_players, ..} => match action { - Action::Join{seat, chips: 0} if self.state.user_has_joined(username) => Err(ActionError::AlreadyJoined), + Action::Join{seat, chips: 0} if self.state.player_has_joined(username) => Err(ActionError::AlreadyJoined), Action::Join{seat, chips: 0} if self.state.has_started() => Err(ActionError::GameHasStarted), - Action::Join{seat, chips: 0} if !self.state.seat_is_available(*seat) => Err(ActionError::NoSeatAvailable), + Action::Join{seat, chips: 0} if *seat >= max_players => Err(ActionError::NoSeatAvailable), + Action::Join{seat, chips: 0} if !self.state.seat_is_available(*seat) => Err(ActionError::SeatNotAvailable), Action::Join{seat, chips: 0} => Ok(()), - Action::Leave if self.state.user_has_joined(username) && !self.state.has_started() => Ok(()), - Action::Leave if self.state.user_has_joined(username) => Err(ActionError::GameHasStarted), + Action::Leave if self.state.player_has_joined(username) && !self.state.has_started() => Ok(()), + Action::Leave if self.state.player_has_joined(username) => Err(ActionError::GameHasStarted), Action::Leave => Err(ActionError::NotAuthorised), Action::ReceiveCard{card: Some(card)} if self.state.is_dealing_to(username) => Ok(()), Action::ReceiveCard{..} => Err(ActionError::OutOfTurn), Action::NextToDeal => Ok(()), - Action::CommunityCard{..} if self.state.is_dealing_community_cards() => Ok(()), - Action::CommunityCard{..} => Err(ActionError::OutOfTurn), - Action::PlayCard{card} if self.state.player_is_active(username) && self.state.player_has_card(username, *card) => Ok(()), + Action::EndDeal => Ok(()), + Action::CommunityCard{..} => Ok(()), + Action::PlayCard{card} if self.state.player_is_active(username) && !self.state.all_players_have_played_a_card() && self.state.player_has_card(username, *card) => Ok(()), Action::PlayCard{card} if !self.state.player_is_active(username) => Err(ActionError::OutOfTurn), Action::PlayCard{..} => Err(ActionError::CardNotPlayable), Action::WinTrick{..} => Ok(()), Action::WinHand{..} => Ok(()), Action::WinGame => Ok(()), + Action::KnockedOut => Ok(()), _ => Err(ActionError::InvalidActionForGameType), } } @@ -215,9 +244,9 @@ impl Game { pub fn take_action(&mut self, user_action: UserAction) -> Result<(), ActionError> { self.verify(&user_action)?; debug!("Taking action: {:?}", user_action); - debug!("State before: {:?}", self.state); + //debug!("State before: {:?}", self.state); self.state.take_action(user_action); - debug!("State after: {:?}", self.state); + //debug!("State after: {:?}", self.state); Ok(()) } } diff --git a/src/games/chatroom.rs b/src/games/chatroom.rs new file mode 100644 index 0000000..8bb476c --- /dev/null +++ b/src/games/chatroom.rs @@ -0,0 +1,92 @@ +use std::collections::HashSet; + +use crate::username::{DEALER, Username}; +use crate::game::{Action, ActionError}; + +use super::{Game, UserAction}; +use super::ValidatedUserAction; + +#[derive(Debug, Clone)] +enum ChatroomAction { + Join, + Message(String), + Leave, +} + +#[derive(Debug, Clone)] +pub struct ChatroomSettings { + title: String, +} + +#[derive(Debug, Clone)] +pub struct Chatroom { + id: u32, + settings: ChatroomSettings, + messages: Vec<(Username, ChatroomAction)>, + users: HashSet, +} + +impl Chatroom { + pub fn new(id: u32, settings: ChatroomSettings) -> Self { + Chatroom { + id, + settings, + messages: Vec::new(), + users: HashSet::new(), + } + } +} + +impl Game for Chatroom { + fn id(&self) -> u32 { + self.id + } + + fn players(&self) -> HashSet { + self.users.clone() + } + + fn actions_len(&self) -> usize { + self.messages.len() + } + + fn validate_action(&self, action: UserAction) -> Result { + match action.action { + Action::Join{..} if !self.users.contains(&action.username) => Ok(ValidatedUserAction(action)), + Action::Join{..} => Err(ActionError::AlreadyJoined), + Action::Message{..} if self.users.contains(&action.username) => Ok(ValidatedUserAction(action)), + Action::Message{..} => Err(ActionError::NotAuthorised), + Action::Leave if self.users.contains(&action.username) => Ok(ValidatedUserAction(action)), + Action::Leave => Err(ActionError::NotAuthorised), + _ => Err(ActionError::InvalidActionForGameType), + } + } + + fn take_action(&mut self, action: ValidatedUserAction) -> Result<(), ActionError> { + let ValidatedUserAction(action) = action; + match action.action { + Action::Join{..} => { + self.messages.push((action.username, ChatroomAction::Join)); + self.users.insert(action.username); + Ok(()) + } + Action::Message{message} => { + self.messages.push((action.username, ChatroomAction::Message(message))); + Ok(()) + } + Action::Leave => { + self.messages.push((action.username, ChatroomAction::Leave)); + self.users.remove(&action.username); + Ok(()) + } + _ => Err(ActionError::InvalidActionForGameType), + } + } + + fn next_dealer_action(&self) -> Option { + match self.messages.len() { + n if n % 10 == 0 => Some(ValidatedUserAction(UserAction{username: DEALER, action: Action::Message{message: format!("{} messages posted so far", n)}})), + _ => None, + } + } +} diff --git a/src/games/mod.rs b/src/games/mod.rs new file mode 100644 index 0000000..8527842 --- /dev/null +++ b/src/games/mod.rs @@ -0,0 +1,57 @@ +pub mod chatroom; + +use std::collections::HashSet; +use std::fmt::Debug; + +use crate::game::{Action, ActionError, GameSummary}; +use crate::username::Username; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ValidatedUserAction(UserAction); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAction { + pub username: Username, + pub action: Action, +} + +impl ValidatedUserAction { + pub fn view_for(&self, username: Username) -> UserAction { + UserAction { + username: self.0.username.clone(), + action: if username == self.0.username { self.0.action.clone() } else { self.0.action.anonymise() }, + } + } +} + +pub trait Game : Debug + CloneBox + Send + Sync { + fn id(&self) -> u32; + fn players(&self) -> HashSet; + fn actions_len(&self) -> usize; + fn validate_action(&self, action: UserAction) -> Result; + fn take_action(&mut self, action: ValidatedUserAction) -> Result<(), ActionError>; + fn next_dealer_action(&self) -> Option; +} + +pub trait CloneBox { + fn clone_box(&self) -> Box; +} + +impl CloneBox for T { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +impl dyn Game { + pub fn new(summary: GameSummary) -> Box { + todo!() + } +} diff --git a/src/games/poker.rs b/src/games/poker.rs new file mode 100644 index 0000000..ba08f22 --- /dev/null +++ b/src/games/poker.rs @@ -0,0 +1,79 @@ +pub enum TexasHoldEm { + NotYetStarted { + seats: Seats, + stacks: HashMap, + } + Dealing { + dealer: Username, + hands: HashMap>, + deck: HashSet, + seats: Seats, + stacks: HashMap, + } + PostSmallBlind { + dealer: Username, + action: Username, + hands: HashMap>, + deck: HashSet, + seats: Seats, + bets: HashMap, + players: HashSet, + stacks: HashMap, + } + PostBigBlind { + dealer: Username, + action: Username, + hands: HashMap>, + deck: HashSet, + seats: Seats, + bets: HashMap, + players: HashSet, + stacks: HashMap, + } + PreFlopBetting { + dealer: Username, + action: Username, + hands: HashMap>, + deck: HashSet, + seats: Seats, + pot: u32, + bets: HashMap, + players: HashSet, + stacks: HashMap, + } + DealFirstFlopCard { + dealer: Username, + action: Username, + flop: [Card; 1], + hands: HashMap>, + deck: HashSet, + seats: Seats, + pot: u32, + players: HashSet, + stacks: HashMap, + } + DealSecondFlopCard { + dealer: Username, + action: Username, + flop: [Card; 2], + hands: HashMap>, + deck: HashSet, + seats: Seats, + pot: u32, + players: HashSet, + stacks: HashMap, + } + DealThirdFlopCard { + dealer: Username, + action: Username, + flop: [Card; 3], + hands: HashMap>, + deck: HashSet, + seats: Seats, + pot: u32, + players: HashSet, + stacks: HashMap, + } + PostFlopBetting { + } +} diff --git a/src/games/whist.rs b/src/games/whist.rs new file mode 100644 index 0000000..a4ad244 --- /dev/null +++ b/src/games/whist.rs @@ -0,0 +1,36 @@ +pub enum KnockOutWhist { + NotYetStarted { + seats: Seats, + } + Dealing { + dealer: Username, + call: Username, + deck: HashSet, + hands: HashMap>, + trump_card: Option, + seats: Seats, + cards_to_deal: u32, + } + ChoosingTrumps { + dealer: Username, + call: Username, + hands: HashMap>, + seats: Seats, + } + Playing { + turn: Username, + trumps: Suit, + trick: HashMap, + hands: HashMap>, + seats: Seats, + tricks_won: HashMap, + } + CutForCall { + winners: HashSet, + cards: HashMap, + seats: Seats, + } + Completed { + winner: Username, + } +} diff --git a/src/gamestate.rs b/src/gamestate.rs index 3aeb831..3b9c853 100644 --- a/src/gamestate.rs +++ b/src/gamestate.rs @@ -2,17 +2,36 @@ use std::collections::{BTreeMap, HashSet}; use crate::card::*; use crate::game::{Action, UserAction}; +use crate::hands::Hands; use crate::seats::Seats; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameState { actions: Vec, + seats: Seats, + players_in_hand: Seats, + hands: Hands, + trumps: Option, + dealing_to: Option, + dealer: Option, + active_player: Option, + next_active_player: Option, + has_started: bool, } impl GameState { pub fn new() -> Self { Self { actions: Vec::new(), + seats: Seats::new(), + players_in_hand: Seats::new(), + hands: Hands::new(), + trumps: None, + dealing_to: None, + dealer: None, + active_player: None, + next_active_player: None, + has_started: false, } } @@ -23,6 +42,7 @@ impl GameState { pub fn view_for(&self, username: &str) -> Self { Self { actions: self.actions.iter().map(|action| action.view_for(username)).collect(), + ..self.clone() } } @@ -30,135 +50,126 @@ impl GameState { self.actions.iter().skip(from).map(|action| action.view_for(username)).collect() } - pub fn user_has_joined(&self, user: &str) -> bool { - let mut logged_in = false; - for action in &self.actions { - match action { - UserAction{ref username, action: Action::Join{..}} if user == username => logged_in = true, - UserAction{ref username, action: Action::Leave} if user == username => logged_in = false, - _ => continue, - } - } - logged_in + pub fn player_has_joined(&self, username: &str) -> bool { + self.seats.contains_player(username) } pub fn players(&self) -> HashSet<&str> { - let mut players = HashSet::new(); - for action in &self.actions { - match action.action { - Action::Join{..} => players.insert(&*action.username), - Action::Leave => players.remove(&*action.username), - _ => continue, - }; - } - players + self.seats.player_set() } - pub fn num_players(&self) -> u32 { - let mut num_players = 0; - for action in &self.actions { - match action.action { - Action::Join{..} => num_players += 1, - Action::Leave => num_players -= 1, - _ => continue, - } - } - num_players + pub fn players_len(&self) -> u32 { + self.seats.players_len() as u32 } - pub fn is_dealing_to(&self, username: &str) -> bool { - let mut is_dealing_to = None; - let mut seats = Seats::new(); - for action in &self.actions { - match &action.action { - Action::Join{seat, ..} => seats.add_player(*seat, &action.username), - Action::Leave => seats.remove_player(&action.username), - Action::NextToDeal => is_dealing_to = seats.player_after(&action.username), - Action::ReceiveCard{..} if is_dealing_to.as_ref().map(String::as_str) == Some(&action.username) => is_dealing_to = seats.player_after(&action.username), - Action::ReceiveCard{..} => error!("Expected {:?} to be dealt a card, but {:?} received one", is_dealing_to, username), - Action::WinHand{..} => is_dealing_to = seats.player_after(&action.username), - Action::EndDeal | Action::WinGame => is_dealing_to = None, - _ => {}, - } - } - is_dealing_to.as_ref().map(String::as_str) == Some(username) + pub fn deck(&self) -> HashSet { + self.hands.deck() } - pub fn seat_is_available(&self, seat: u32) -> bool { - let mut seats = Seats::new(); - for action in &self.actions { - match &action.action { - Action::Join{seat, ..} => seats.add_player(*seat, &action.username), - Action::Leave => seats.remove_player(&action.username), - _ => {}, - } - } - seats.seat_is_available(seat) + pub fn dealer(&self) -> Option { + self.dealer.clone() } - pub fn is_choosing_next_to_deal(&self) -> bool { - for action in &self.actions { - if let Action::NextToDeal = &action.action { - return false; - } - } - true + pub fn dealing_to(&self) -> Option { + self.dealing_to.clone() + } + + pub fn is_dealing_to(&self, username: &str) -> bool { + self.dealing_to.as_ref().map(String::as_str) == Some(username) + } + + pub fn seat_is_available(&self, seat: u32) -> bool { + self.seats.seat_is_available(seat) } pub fn has_started(&self) -> bool { - for action in &self.actions { - if let Action::NextToDeal = &action.action { - return true; - } - } - false + self.has_started } pub fn player_is_active(&self, username: &str) -> bool { - let mut dealer = None; - let mut active_player = None; - let mut next_active_player = None; - let mut seats = Seats::new(); - let mut players_in_hand = Seats::new(); - for action in &self.actions { - println!("processing action: {:?}", action); - match &action.action { - Action::Join{seat, ..} => seats.add_player(*seat, &action.username), - Action::Leave => seats.remove_player(&action.username), - Action::NextToDeal => { - players_in_hand = seats.clone(); - next_active_player = players_in_hand.player_after(&action.username); - dealer = Some(action.username.clone()); - } - Action::PlayCard{..} | Action::Bet{..} => { - active_player = players_in_hand.player_after(&action.username); - } - Action::Fold => { - active_player = players_in_hand.player_after(&action.username); - players_in_hand.remove_player(&action.username); - } - Action::WinHand{..} => active_player = None, - Action::EndDeal => active_player = next_active_player.clone(), - Action::WinGame => active_player = None, - _ => {}, - } - println!("dealer: {:?}", dealer); - println!("active_player: {:?}", active_player); - println!("next_active_player: {:?}", next_active_player); - println!("seats: {:?}", seats); - } - active_player.as_ref().map(String::as_str) == Some(username) + self.active_player.as_ref().map(String::as_str) == Some(username) } pub fn player_has_card(&self, username: &str, card: Card) -> bool { - false // TODO + self.hands.player_has_card(username, card) + } + + pub fn all_players_have_played_a_card(&self) -> bool { + self.hands.all_players_have_played_a_card() } - pub fn is_dealing_community_cards(&self) -> bool { - false // TODO + pub fn trick_winner(&self) -> Option { + self.hands.trick_winner(self.trumps) } pub fn take_action(&mut self, action: UserAction) { + match &action.action { + Action::Join{seat, ..} => self.seats.add_player(*seat, &action.username), + Action::Leave | Action::KnockedOut => self.seats.remove_player(&action.username), + Action::AddOn{..} => { + // TODO + } + Action::NextToDeal => { + self.players_in_hand = self.seats.clone(); + self.next_active_player = self.players_in_hand.player_after(&action.username); + self.dealer = Some(action.username.clone()); + self.dealing_to = self.seats.player_after(&action.username); + self.has_started = true; + } + Action::ReceiveCard{card: Some(card)} if self.dealing_to.as_ref().map(String::as_str) == Some(&action.username) => { + self.dealing_to = self.seats.player_after(&action.username); + self.hands.deal_card(action.username.clone(), *card); + } + Action::ReceiveCard{..} => { + error!("Expected {:?} to be dealt a card, but {:?} received one", self.dealing_to, action.username); + } + Action::CommunityCard{card} => { + self.trumps = Some(card.suit); + self.hands.deal_community_card(*card); + } + Action::RevealCard{card} => { + self.hands.reveal_card(&action.username, *card); + } + Action::ChooseTrumps{suit} => { + self.trumps = Some(*suit); + } + Action::PlayCard{card} => { + self.active_player = self.players_in_hand.player_after(&action.username); + self.hands.play_card(action.username.clone(), *card); + } + Action::Bet{..} => { + self.active_player = self.players_in_hand.player_after(&action.username); + } + Action::Fold => { + self.active_player = self.players_in_hand.player_after(&action.username); + self.players_in_hand.remove_player(&action.username); + } + Action::WinTrick => { + self.active_player = Some(action.username.clone()); + self.hands.clear_trick(); + } + Action::WinHand{..} => { + self.active_player = None; + self.dealing_to = self.seats.player_after(&action.username); + if let Some(dealer) = self.dealer.take() { + self.dealer = self.seats.player_after(&dealer); + if let Some(dealer) = &self.dealer { + self.dealing_to = self.seats.player_after(&dealer); + } + } + self.hands.clear(); + } + Action::EndDeal => { + self.active_player = self.next_active_player.clone(); + self.dealing_to = None; + } + Action::WinGame => { + self.active_player = None; + self.dealing_to = None; + self.hands.clear(); + } + Action::Message{..} => {} + } self.actions.push(action); } } @@ -168,21 +179,21 @@ mod tests { use super::*; #[test] - fn user_has_joined() { + fn player_has_joined() { let mut state = GameState::new(); state.take_action(UserAction{username: "user".to_string(), action: Action::Join{seat: 0, chips: 10000}}); - assert!(state.user_has_joined("user")); + assert!(state.player_has_joined("user")); } #[test] - fn num_players() { + fn players_len() { let mut state = GameState::new(); state.take_action(UserAction{username: "user".to_string(), action: Action::Join{seat: 0, chips: 10000}}); - assert_eq!(1, state.num_players()); + assert_eq!(1, state.players_len()); state.take_action(UserAction{username: "user2".to_string(), action: Action::Join{seat: 1, chips: 10000}}); - assert_eq!(2, state.num_players()); + assert_eq!(2, state.players_len()); state.take_action(UserAction{username: "user".to_string(), action: Action::Leave}); - assert_eq!(1, state.num_players()); + assert_eq!(1, state.players_len()); } #[test] @@ -226,7 +237,7 @@ mod tests { } #[test] - fn player_is_active() { + fn player_is_active_hold_em() { let mut state = GameState::new(); state.take_action(UserAction{username: "user1".to_string(), action: Action::Join{seat: 0, chips: 10000}}); state.take_action(UserAction{username: "user2".to_string(), action: Action::Join{seat: 1, chips: 10000}}); @@ -255,4 +266,70 @@ mod tests { state.take_action(UserAction{username: "user1".to_string(), action: Action::CommunityCard{card: KING_OF_CLUBS}}); state.take_action(UserAction{username: "user1".to_string(), action: Action::CommunityCard{card: THREE_OF_HEARTS}}); } + + #[test] + fn player_is_active_whist() { + let mut state = GameState::new(); + state.take_action(UserAction{username: "user1".to_string(), action: Action::Join{seat: 0, chips: 0}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::Join{seat: 1, chips: 0}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::Join{seat: 2, chips: 0}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::NextToDeal}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(TWO_OF_CLUBS)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(TWO_OF_DIAMONDS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(TWO_OF_HEARTS)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(TWO_OF_SPADES)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(THREE_OF_CLUBS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(THREE_OF_HEARTS)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(THREE_OF_DIAMONDS)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(THREE_OF_SPADES)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(FOUR_OF_CLUBS)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(FOUR_OF_DIAMONDS)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(FOUR_OF_HEARTS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(FOUR_OF_SPADES)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(FIVE_OF_CLUBS)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(FIVE_OF_DIAMONDS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(FIVE_OF_HEARTS)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(FIVE_OF_SPADES)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(SIX_OF_CLUBS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(SIX_OF_DIAMONDS)}}); + state.take_action(UserAction{username: "user2".to_string(), action: Action::ReceiveCard{card: Some(SIX_OF_HEARTS)}}); + state.take_action(UserAction{username: "user3".to_string(), action: Action::ReceiveCard{card: Some(SIX_OF_SPADES)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::ReceiveCard{card: Some(SEVEN_OF_CLUBS)}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::CommunityCard{card: SEVEN_OF_DIAMONDS}}); + state.take_action(UserAction{username: "user1".to_string(), action: Action::EndDeal}); + assert!(state.player_is_active("user2")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user2".to_string(), action: Action::PlayCard{card: FIVE_OF_CLUBS}}); + assert!(state.player_is_active("user3")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user3".to_string(), action: Action::PlayCard{card: SIX_OF_CLUBS}}); + assert!(state.player_is_active("user1")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user1".to_string(), action: Action::PlayCard{card: SEVEN_OF_CLUBS}}); + assert!(state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user1".to_string(), action: Action::WinTrick}); + assert!(state.player_is_active("user1")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user1".to_string(), action: Action::PlayCard{card: FIVE_OF_HEARTS}}); + assert!(state.player_is_active("user2")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user2".to_string(), action: Action::PlayCard{card: SIX_OF_HEARTS}}); + assert!(state.player_is_active("user3")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user3".to_string(), action: Action::PlayCard{card: FOUR_OF_HEARTS}}); + assert!(state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user3".to_string(), action: Action::WinTrick}); + assert!(state.player_is_active("user3")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user3".to_string(), action: Action::PlayCard{card: SIX_OF_SPADES}}); + assert!(state.player_is_active("user1")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user1".to_string(), action: Action::PlayCard{card: FOUR_OF_SPADES}}); + assert!(state.player_is_active("user2")); + assert!(!state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user2".to_string(), action: Action::PlayCard{card: TWO_OF_SPADES}}); + assert!(state.all_players_have_played_a_card()); + state.take_action(UserAction{username: "user3".to_string(), action: Action::WinTrick}); + assert!(state.player_is_active("user3")); + } } diff --git a/src/hands.rs b/src/hands.rs new file mode 100644 index 0000000..c4b95bb --- /dev/null +++ b/src/hands.rs @@ -0,0 +1,96 @@ +use std::collections::{HashMap, HashSet}; + +use itertools::Itertools; + +use crate::card::{Card, Suit, FIFTY_TWO_CARD_DECK}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hands { + hands: HashMap>, + community: Vec, + trick: Vec<(String, Card)>, + deck: HashSet, +} + +impl Hands { + pub fn new() -> Hands { + Hands { + hands: HashMap::new(), + community: Vec::new(), + trick: Vec::new(), + deck: FIFTY_TWO_CARD_DECK.iter().cloned().collect(), + } + } + + pub fn clear(&mut self) { + self.hands.clear(); + self.community.clear(); + self.trick.clear(); + self.deck = FIFTY_TWO_CARD_DECK.iter().cloned().collect(); + } + + pub fn clear_trick(&mut self) { + self.trick.clear(); + } + + pub fn trick_winner(&self, trumps: Option) -> Option { + let mut winner = None; + for (player, card) in &self.trick { + match winner { + None => winner = Some((player, card)), + Some((_, best)) if + Some(card.suit) == trumps && (card.rank > best.rank || Some(best.suit) != trumps) || + card.suit == best.suit && card.rank > best.rank + => winner = Some((player, card)), + Some((_, _)) => {}, + } + } + winner.map(|(player, _)| (*player).clone()) + } + + pub fn all_players_have_played_a_card(&self) -> bool { + let all_players: HashSet<_> = self.hands.keys().collect(); + let players_that_have_played_a_card: HashSet<_> = self.trick.iter().map(|(player, _)| player).collect(); + all_players == players_that_have_played_a_card + } + + pub fn play_card(&mut self, username: String, card: Card) { + if let Some(set) = self.hands.get_mut(&username) { + if set.remove(&card) { + self.trick.push((username, card)); + } else { + error!("Tried to remove {} from {}'s hand - had [{}]", card, username, set.iter().format(", ")); + } + } else { + error!("Tried to remove {} from {}'s hand - had [{}]", card, username, self.hands.keys().format(", ")); + } + } + + pub fn reveal_card(&mut self, username: &str, card: Card) { + // TODO + } + + pub fn deck(&self) -> HashSet { + self.deck.clone() + } + + pub fn deal_card(&mut self, username: String, card: Card) { + if !self.deck.remove(&card) { + error!("Tried to deal card {} but it was not present in deck: [{}]", card, self.deck.iter().format(", ")); + } + let set = self.hands.entry(username).or_insert_with(HashSet::new); + set.insert(card); + } + + pub fn deal_community_card(&mut self, card: Card) { + self.community.push(card); + } + + pub fn player_has_card(&self, username: &str, card: Card) -> bool { + if let Some(hand) = self.hands.get(username) { + hand.contains(&card) + } else { + false + } + } +} diff --git a/src/main.rs b/src/main.rs index 4eb2784..1e0b4f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use std::convert::TryFrom; use std::mem::swap; +use async_std::task::spawn; use futures::{channel::mpsc::{channel, Receiver}, future::{Either, select}, select, FutureExt, SinkExt, StreamExt, pin_mut, stream::{self, FuturesUnordered}}; use redis::{aio::PubSub, Client, Msg}; use signal_hook::consts::signal::*; @@ -18,14 +19,20 @@ mod api; mod auth; mod card; mod client; +mod dealer; mod game; +mod games; mod gamestate; +mod hands; mod seats; mod server; +mod username; use crate::api::ServerMessage; -use crate::server::{ClientInterestSender, Server, ServerState}; use crate::client::{ClientInterest, ConnectionState}; +use crate::dealer::Dealer; +use crate::game::GameList; +use crate::server::{ClientInterestSender, Server, ServerState}; pub async fn handle_client_interest(mut connection: PubSub, mut new_clients: Receiver) -> Result<(), std::io::Error> { #[derive(Debug)] @@ -79,7 +86,7 @@ pub async fn handle_client_interest(mut connection: PubSub, mut new_clients: Rec action } { Action::AddClient{sender} => { - debug!("handle_client_interest: Action::AddClient {{ sender: {:?} }}", sender); + debug!("handle_client_interest: Action::AddClient {{ clients[{}] }}", clients.len()); clients.push(Client{sender, interests: HashSet::new()}); } Action::RegisterInterest{index, mut client_interests} => { @@ -102,7 +109,7 @@ pub async fn handle_client_interest(mut connection: PubSub, mut new_clients: Rec let client = &mut clients[index]; let sender = &mut client.sender; for interest in &client.interests - &client_interests { - debug!("handle_client_interest: Sending initial interest for new interest {:?} to {:?}", interest, sender); + debug!("handle_client_interest: Sending initial interest for new interest {:?} to clients[{}]", interest, index); if let Err(err) = sender.interest.send(interest.clone()).await { error!("handle_client_interest: Send failed: {}", err); } @@ -147,6 +154,33 @@ pub async fn handle_client_interest(mut connection: PubSub, mut new_clients: Rec } } +async fn handle_new_games(server: Server) -> Result<(), std::io::Error> { + let (mut server_state, mut update_stream) = server.new_state().await; + let mut interests = HashSet::new(); + interests.insert(ClientInterest::GameList); + server_state.register_interests(interests).await; + let mut game_list = GameList::new("".to_string()); + loop { + let games_len = game_list.games_len(); + match server_state.update_game_list(&mut game_list).await { + Ok(()) => { + for summary in game_list.update(games_len) { + let (server_state, update_stream) = server.new_state().await; + if let Ok(dealer) = Dealer::new(server_state, summary.id()).await { + spawn(dealer.start(update_stream)); + } + } + } + Err(err) => { + error!("Failed to update game list: {}", err); + return Err(std::io::Error::new(std::io::ErrorKind::Other, err)); + } + } + if let Some(ClientInterest::GameList) = update_stream.next().await { continue; } + return Ok(()); + } +} + pub async fn handle_websocket_request(request: Request, stream: WebSocketConnection) -> Result<(), Error> { let (server_state, update_stream): (ServerState, Receiver<_>) = request.state().new_state().await; let mut client = ConnectionState::new(server_state); @@ -226,6 +260,7 @@ async fn main() -> Result<(), Error> { let signal_handler = handle_signals(signals); let handle_client_interest = handle_client_interest(pubsub, register_update_stream_rx); + let handle_new_games = handle_new_games(app.state().clone()); /*let listener = TlsListener::build() .addrs("localhost:4433") @@ -234,9 +269,9 @@ async fn main() -> Result<(), Error> { let app = app.listen(listener);*/ let app = app.listen("0.0.0.0:8080"); - pin_mut!(app, handle_client_interest, signal_handler); + pin_mut!(app, handle_client_interest, handle_new_games, signal_handler); - select(select(app, handle_client_interest).map(|f| f.factor_first().0), signal_handler).await.factor_first().0?; + select(select(app, select(handle_client_interest, handle_new_games).map(|f| f.factor_first().0)).map(|f| f.factor_first().0), signal_handler).await.factor_first().0?; info!("Pokerwave shut down gracefully."); diff --git a/src/seats.rs b/src/seats.rs index d32cfa8..bb861b3 100644 --- a/src/seats.rs +++ b/src/seats.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Seats { players: BTreeMap, } @@ -41,6 +41,19 @@ impl Seats { None } + pub fn contains_player(&self, username: &str) -> bool { + for (_, player) in &self.players { + if player == username { + return true; + } + } + false + } + + pub fn player_set(&self) -> HashSet<&str> { + self.players.iter().map(|(_, player)| &**player).collect() + } + pub fn seat_is_available(&self, seat: u32) -> bool { self.players.get(&seat).is_none() } diff --git a/src/server.rs b/src/server.rs index 5886448..5e4ecbe 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,7 +7,9 @@ use serde::{Serialize, Deserialize}; use crate::auth::Auth; use crate::client::ClientInterest; -use crate::game::{Game, GameList, GameSettings, GameSummary, UserAction}; +use crate::game::{GameList, GameSettings, GameSummary, UserAction}; +use crate::games::ValidatedUserAction; +use crate::username::Username; #[derive(Clone)] pub struct Server { @@ -39,6 +41,7 @@ fn client_interest_channel() -> (ClientInterestSender, ClientInterestReceiver) { pub enum ClientInterestFromMsgError { InvalidChannelName{channel_name: String}, + UsernameParseError(&'static str), } impl TryFrom for ClientInterest { @@ -48,7 +51,8 @@ impl TryFrom for ClientInterest { if channel_name == "__keyspace@0__:games" { Ok(ClientInterest::GameList) } else if let Some(username) = channel_name.strip_prefix("__keyspace@0__:user:") { - Ok(ClientInterest::User{username: username.to_string()}) + username.parse().map_err(ClientInterestFromMsgError::UsernameParseError) + .map(|username| ClientInterest::User{username}) } else if let Some(Ok(id)) = channel_name.strip_prefix("__keyspace@0__:game:").map(str::parse) { Ok(ClientInterest::Game{id}) } else { @@ -106,7 +110,7 @@ pub struct ServerState { take_action_script: Script, } -fn user_key(username: &str) -> String { +fn user_key(username: Username) -> String { format!("user:{}", username) } @@ -115,7 +119,7 @@ fn game_key(id: u32) -> String { } impl ServerState { - pub async fn create_user(&mut self, username: &str, auth: Auth, nickname: &str) -> RedisResult<()> { + pub async fn create_user(&mut self, username: Username, auth: Auth, nickname: &str) -> RedisResult<()> { let key = user_key(username); if self.redis.hset_nx::<_, _, _, i32>(&key, "auth", AsJson(auth)).await? == 0 { return Err(RedisError::from((ErrorKind::ResponseError, "User already exists"))); @@ -123,22 +127,22 @@ impl ServerState { self.redis.hset(&key, "nickname", nickname).await } - pub async fn set_user_auth(&mut self, username: &str, auth: Auth) -> RedisResult<()> { + pub async fn set_user_auth(&mut self, username: Username, auth: Auth) -> RedisResult<()> { let key = user_key(username); self.redis.hset(key, "auth", AsJson(auth)).await } - async fn get_user_auth(&mut self, username: &str) -> Option { + async fn get_user_auth(&mut self, username: Username) -> Option { let key = user_key(username); self.redis.hget(key, "auth").await.ok().map(AsJson::get) } - pub async fn set_user_nickname(&mut self, username: &str, nickname: &str) -> RedisResult<()> { + pub async fn set_user_nickname(&mut self, username: Username, nickname: &str) -> RedisResult<()> { let key = user_key(username); self.redis.hset(key, "nickname", nickname).await } - pub async fn verify(&mut self, username: &str, challenge: &str, signature: &str) -> bool { + pub async fn verify(&mut self, username: Username, challenge: &str, signature: &str) -> bool { match self.get_user_auth(username).await { None => false, Some(auth) => auth.verify(challenge, signature), @@ -164,27 +168,18 @@ impl ServerState { Ok(()) } - pub async fn update_game_state(&mut self, game: &mut Game) -> RedisResult<()> { - const GAME_ACTION_BLOCK_SIZE: isize = 1024; - let key = game_key(game.id()); - for i in (game.actions_len() as isize..).step_by(GAME_ACTION_BLOCK_SIZE as usize) { - let actions: Vec> = self.redis.lrange(&key, i, i + GAME_ACTION_BLOCK_SIZE).await?; - if actions.is_empty() { break; } - for AsJson(action) in actions { - game.take_action(action).map_err(|err| RedisError::from((ErrorKind::ResponseError, "Invalid action", err.to_string())))?; - } - } - Ok(()) + pub async fn game_state(&mut self, id: u32, from: usize) -> RedisResult> { + let key = game_key(id); + let actions: Vec> = self.redis.lrange(&key, from as isize, -1).await?; + Ok(actions.into_iter().map(AsJson::get).collect()) } - pub async fn get_game(&mut self, id: u32) -> RedisResult { + pub async fn game_summary(&mut self, id: u32) -> RedisResult { let settings = self.redis.lindex("games", id as isize).await.map(AsJson::get)?; - let mut game = Game::new(id, settings); - self.update_game_state(&mut game).await?; - Ok(game) + Ok(GameSummary::new(id, settings)) } - pub async fn take_action(&mut self, id: u32, len: usize, action: &UserAction) -> RedisResult { + pub async fn take_action(&mut self, id: u32, len: usize, action: &ValidatedUserAction) -> RedisResult { let key = game_key(id); debug!("take_action: EVAL {{TAKE_ACTION_LUA_SCRIPT}} 1 {} {} {:?}", key, len, action); self.take_action_script.key(key).arg(len).arg(AsJson(action)).invoke_async(&mut self.redis).await diff --git a/src/username.rs b/src/username.rs new file mode 100644 index 0000000..4313ad5 --- /dev/null +++ b/src/username.rs @@ -0,0 +1,109 @@ +use std::fmt::{self, Display, Formatter}; +use std::str::{self, FromStr, Utf8Error}; + +use serde::{Serialize, Deserialize, Serializer, Deserializer, ser::Error, de::{self, Visitor}}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(align(8))] +pub struct Username { + username: [u8; Self::MAX_LENGTH], +} + +impl Username { + const MIN_LENGTH: usize = 1; + const MAX_LENGTH: usize = 32; + + fn as_str(&self) -> Result<&str, Utf8Error> { + str::from_utf8(&self.username) + .map(|str| str.trim_end_matches('\0')) + } +} + +pub const DEALER: Username = Username { + username: [ + b'd', b'e', b'a', b'l', b'e', b'r', 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + ], +}; + +impl Display for Username { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self.as_str() { + Ok(str) => f.write_str(str), + Err(_) => f.write_str(String::from_utf8_lossy(&self.username).trim_end_matches('\0')), + } + } +} + +impl FromStr for Username { + type Err = &'static str; + + fn from_str(str: &str) -> Result { + let bytes = str.as_bytes(); + let len = bytes.len(); + if len < Username::MIN_LENGTH { + Err("string was too short") + } else if len > Username::MAX_LENGTH { + Err("string was too long") + } else if bytes.iter().any(|b| !b.is_ascii_alphanumeric() && *b != b'_') { + Err("string contained values not in [A-Za-z0-9_]") + } else { + let mut username = [0; 32]; + username[0..len].copy_from_slice(bytes); + Ok(Username{username}) + } + } +} + +impl Serialize for Username { + fn serialize(&self, serializer: S) -> Result + where S: Serializer + { + match self.as_str() { + Ok(str) => serializer.serialize_str(str), + Err(err) => Err(S::Error::custom(err)), + } + } +} + +impl<'de> Deserialize<'de> for Username { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + struct StrVisitor; + impl Visitor<'_> for StrVisitor { + type Value = Username; + fn expecting(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "a string containing {}-{} characters [A-Za-z0-9_]", Username::MIN_LENGTH, Username::MAX_LENGTH) + } + + fn visit_str(self, str: &str) -> Result + where E: de::Error + { + str.parse().map_err(E::custom) + } + } + deserializer.deserialize_str(StrVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_username() { + let username: Username = serde_json::from_str(r#""user1234""#).unwrap(); + assert_eq!(username, Username{username: [b'u', b's', b'e', b'r', b'1', b'2', b'3', b'4', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}); + assert!(serde_json::from_str::(r#""\0""#).is_err()); + assert!(serde_json::from_str::(r#""this_is_a_very_long_username_that_fails_to_parse""#).is_err()); + } + + #[test] + fn username_to_string() { + let username: Username = serde_json::from_str(r#""user1234""#).unwrap(); + assert_eq!("user1234", username.to_string()); + } +} -- 2.34.1