add deterministic rng
authorGeoffrey Allott <geoffrey@allott.email>
Mon, 1 Mar 2021 21:14:23 +0000 (21:14 +0000)
committerGeoffrey Allott <geoffrey@allott.email>
Mon, 1 Mar 2021 21:14:23 +0000 (21:14 +0000)
16 files changed:
Cargo.lock
Cargo.toml
site/modules/poker.js [new file with mode: 0644]
site/modules/socket.js
site/style.css
site/style/poker.css [new file with mode: 0644]
src/client.rs
src/dealer.rs
src/game/action.rs
src/game/mod.rs
src/game/poker/holdem.rs
src/game/whist.rs
src/main.rs
src/rng.rs [new file with mode: 0644]
src/seats.rs
src/server.rs

index c350a6c27353b5642c91f3f5c521c36a21ca6a25..25cc1dea13f520963a500ca4acf557529ac722ae 100644 (file)
@@ -228,7 +228,7 @@ dependencies = [
  "async-io",
  "async-lock",
  "async-process",
- "crossbeam-utils 0.8.2",
+ "crossbeam-utils 0.8.3",
  "futures-channel",
  "futures-core",
  "futures-io",
@@ -500,14 +500,13 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bae8f328835f8f5a6ceb6a7842a7f2d0c03692adb5c889347235d59194731fe3"
+checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49"
 dependencies = [
  "autocfg",
  "cfg-if 1.0.0",
  "lazy_static",
- "loom",
 ]
 
 [[package]]
@@ -733,19 +732,6 @@ dependencies = [
  "slab",
 ]
 
-[[package]]
-name = "generator"
-version = "0.6.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9fed24fd1e18827652b4d55652899a1e9da8e54d91624dc3437a5bc3a9f9a9c"
-dependencies = [
- "cc",
- "libc",
- "log",
- "rustversion",
- "winapi",
-]
-
 [[package]]
 name = "generic-array"
 version = "0.14.4"
@@ -810,6 +796,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "hex"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "644f9158b2f133fd50f5fb3242878846d9eb792e445c893805ff0e3824006e35"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "hkdf"
 version = "0.10.0"
@@ -940,9 +935,9 @@ checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
 
 [[package]]
 name = "js-sys"
-version = "0.3.47"
+version = "0.3.48"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cfb73131c35423a367daf8cbd24100af0d077668c8c2943f0e7dd775fef0f65"
+checksum = "dc9f84f9b115ce7843d60706df1422a916680bfdfcbdb0447c5614ff9d7e4d78"
 dependencies = [
  "wasm-bindgen",
 ]
@@ -978,17 +973,6 @@ dependencies = [
  "value-bag",
 ]
 
-[[package]]
-name = "loom"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d44c73b4636e497b4917eb21c33539efa3816741a2d3ff26c6316f1b529481a4"
-dependencies = [
- "cfg-if 1.0.0",
- "generator",
- "scoped-tls",
-]
-
 [[package]]
 name = "matches"
 version = "0.1.8"
@@ -1029,9 +1013,9 @@ dependencies = [
 
 [[package]]
 name = "once_cell"
-version = "1.6.0"
+version = "1.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ad167a2f54e832b82dbe003a046280dceffe5227b5f79e08e363a29638cfddd"
+checksum = "10acf907b94fc1b1a152d08ef97e7759650268cf986bf127f387e602b02c7e5a"
 
 [[package]]
 name = "opaque-debug"
@@ -1096,6 +1080,8 @@ dependencies = [
  "async-std",
  "env_logger",
  "futures",
+ "getrandom 0.2.2",
+ "hex",
  "itertools",
  "log",
  "rand 0.8.3",
@@ -1254,9 +1240,9 @@ dependencies = [
 
 [[package]]
 name = "redis"
-version = "0.19.0"
+version = "0.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a6ddfecac9391fed21cce10e83c65fa4abafd77df05c98b1c647c65374ce9b3"
+checksum = "eeb8f8d059ead7805e171fc22de8348a3d611c0f985aaa4f5cf6c0dfc7645407"
 dependencies = [
  "async-std",
  "async-trait",
@@ -1334,24 +1320,12 @@ dependencies = [
  "webpki",
 ]
 
-[[package]]
-name = "rustversion"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb5d2a036dc6d2d8fd16fde3498b04306e29bd193bf306a57427019b823d5acd"
-
 [[package]]
 name = "ryu"
 version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
 
-[[package]]
-name = "scoped-tls"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2"
-
 [[package]]
 name = "sct"
 version = "0.6.0"
@@ -1399,9 +1373,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.62"
+version = "1.0.64"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea1c6153794552ea7cf7cf63b1231a25de00ec90db326ba6264440fa08e31486"
+checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79"
 dependencies = [
  "itoa",
  "ryu",
@@ -1910,9 +1884,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
 
 [[package]]
 name = "wasm-bindgen"
-version = "0.2.70"
+version = "0.2.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55c0f7123de74f0dab9b7d00fd614e7b19349cd1e2f5252bbe9b1754b59433be"
+checksum = "7ee1280240b7c461d6a0071313e08f34a60b0365f14260362e5a2b17d1d31aa7"
 dependencies = [
  "cfg-if 1.0.0",
  "wasm-bindgen-macro",
@@ -1920,9 +1894,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-backend"
-version = "0.2.70"
+version = "0.2.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bc45447f0d4573f3d65720f636bbcc3dd6ce920ed704670118650bcd47764c7"
+checksum = "5b7d8b6942b8bb3a9b0e73fc79b98095a27de6fa247615e59d096754a3bc2aa8"
 dependencies = [
  "bumpalo",
  "lazy_static",
@@ -1935,9 +1909,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-futures"
-version = "0.4.20"
+version = "0.4.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3de431a2910c86679c34283a33f66f4e4abd7e0aec27b6669060148872aadf94"
+checksum = "8e67a5806118af01f0d9045915676b22aaebecf4178ae7021bc171dab0b897ab"
 dependencies = [
  "cfg-if 1.0.0",
  "js-sys",
@@ -1947,9 +1921,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro"
-version = "0.2.70"
+version = "0.2.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b8853882eef39593ad4174dd26fc9865a64e84026d223f63bb2c42affcbba2c"
+checksum = "e5ac38da8ef716661f0f36c0d8320b89028efe10c7c0afde65baffb496ce0d3b"
 dependencies = [
  "quote",
  "wasm-bindgen-macro-support",
@@ -1957,9 +1931,9 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-macro-support"
-version = "0.2.70"
+version = "0.2.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4133b5e7f2a531fa413b3a1695e925038a05a71cf67e87dafa295cb645a01385"
+checksum = "cc053ec74d454df287b9374ee8abb36ffd5acb95ba87da3ba5b7d3fe20eb401e"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1970,15 +1944,15 @@ dependencies = [
 
 [[package]]
 name = "wasm-bindgen-shared"
-version = "0.2.70"
+version = "0.2.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd4945e4943ae02d15c13962b38a5b1e81eadd4b71214eee75af64a4d6a4fd64"
+checksum = "7d6f8ec44822dd71f5f221a5847fb34acd9060535c1211b70a05844c0f6383b1"
 
 [[package]]
 name = "web-sys"
-version = "0.3.47"
+version = "0.3.48"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c40dc691fc48003eba817c38da7113c15698142da971298003cac3ef175680b3"
+checksum = "ec600b26223b2948cedfde2a0aa6756dcf1fef616f43d7b3097aaf53a6c4d92b"
 dependencies = [
  "js-sys",
  "wasm-bindgen",
index a412efdf9463f0105f9e63f08cd6619e2cbdb3d0..6dadf4e6a733d42ea50c3876f86c593bf5fc2c0d 100644 (file)
@@ -9,10 +9,12 @@ async-std = { version = "1", features = ["attributes"] }
 env_logger = "0.8"
 log = "0.4"
 futures = "0.3"
+getrandom = "0.2"
+hex = { version = "0.4", features = ["serde"] }
 itertools = "0.10"
 rand = "0.8"
 rand_chacha = "0.3"
-redis = { version = "0.19", features = ["async-std-comp"] }
+redis = { version = "0.20", features = ["async-std-comp"] }
 serde = "1"
 serde_derive = "1"
 serde_json = "1"
diff --git a/site/modules/poker.js b/site/modules/poker.js
new file mode 100644 (file)
index 0000000..68892cc
--- /dev/null
@@ -0,0 +1,375 @@
+const svgns = "http://www.w3.org/2000/svg";
+
+import { card_href, suit_href } from "./card.js";
+
+export class TexasHoldEm {
+    constructor(container, summary, actions, username, send) {
+        this.container = container;
+        this.summary = summary;
+        this.actions = actions;
+        this.username = username;
+        this.send = send;
+        this.seats = new Map();
+        this.stacks = new Map();
+        this.hands = new Map();
+        this.bets = new Map();
+        this.pot = 0;
+        this.community = [];
+        this.user_icons = new Map();
+        this.small_blind = this.summary.settings.small_blind;
+        this.big_blind = this.small_blind * 2;
+
+        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);
+
+        const controls = document.createElementNS(svgns, "rect");
+        controls.setAttribute("x", "310");
+        controls.setAttribute("y", "435");
+        controls.setAttribute("width", "185");
+        controls.setAttribute("height", "60");
+        controls.setAttribute("rx", "15");
+        controls.classList.add("controls");
+        this.svg.append(controls);
+
+        this.fold_control = document.createElementNS(svgns, "rect");
+        this.fold_control.setAttribute("x", "315");
+        this.fold_control.setAttribute("y", "440");
+        this.fold_control.setAttribute("width", "55");
+        this.fold_control.setAttribute("height", "50");
+        this.fold_control.setAttribute("rx", "10");
+        this.fold_control.onclick = () => this.send({type: "TakeAction", action: {action: "Fold"}});
+        this.fold_control.classList.add("fold-control");
+        this.svg.append(this.fold_control);
+
+        const fold_control_label = document.createElementNS(svgns, "text");
+        const fold_control_text = document.createTextNode("Fold");
+        fold_control_label.append(fold_control_text);
+        fold_control_label.setAttribute("x", "325");
+        fold_control_label.setAttribute("y", "470");
+        fold_control_label.classList.add("fold-control-label");
+        this.svg.append(fold_control_label);
+
+        this.call_control = document.createElementNS(svgns, "rect");
+        this.call_control.setAttribute("x", "375");
+        this.call_control.setAttribute("y", "440");
+        this.call_control.setAttribute("width", "55");
+        this.call_control.setAttribute("height", "50");
+        this.call_control.setAttribute("rx", "10");
+        this.call_control.onclick = () => this.send({type: "TakeAction", action: {action: "Bet", chips: this.chips_to_call()}});
+        this.call_control.classList.add("call-control");
+        this.svg.append(this.call_control);
+
+        const call_control_label = document.createElementNS(svgns, "text");
+        const call_control_text = document.createTextNode("Call");
+        call_control_label.append(call_control_text);
+        call_control_label.setAttribute("x", "385");
+        call_control_label.setAttribute("y", "470");
+        call_control_label.classList.add("call-control-label");
+        this.svg.append(call_control_label);
+
+        this.bet_control = document.createElementNS(svgns, "rect");
+        this.bet_control.setAttribute("x", "435");
+        this.bet_control.setAttribute("y", "440");
+        this.bet_control.setAttribute("width", "55");
+        this.bet_control.setAttribute("height", "50");
+        this.bet_control.setAttribute("rx", "10");
+        this.bet_control.onclick = () => {
+            const chips = prompt("Chips to bet", this.chips_to_call() + this.min_raise());
+            if (chips !== null && !isNaN(Number(chips))) {
+                this.send({type: "TakeAction", action: {action: "Bet", chips: Number(chips)}});
+            }
+        }
+        this.bet_control.classList.add("bet-control");
+        this.svg.append(this.bet_control);
+
+        const bet_control_label = document.createElementNS(svgns, "text");
+        const bet_control_text = document.createTextNode("Bet");
+        bet_control_label.append(bet_control_text);
+        bet_control_label.setAttribute("x", "445");
+        bet_control_label.setAttribute("y", "470");
+        bet_control_label.classList.add("bet-control-label");
+        this.svg.append(bet_control_label);
+
+        this.container.append(this.svg);
+        for (const user_action of this.actions) {
+            this.take_action(user_action);
+        }
+
+        if (!this.seats.has(this.username)) {
+            this.send({type: "TakeAction", action: {action: "Join", seat: 0, chips: this.summary.settings.starting_stack}});
+        }
+    }
+
+    redraw_players() {
+        this.fold_control.classList.toggle("active", this.active === this.username);
+        this.call_control.classList.toggle("active", this.active === this.username);
+        this.bet_control.classList.toggle("active", this.active === this.username);
+        for (const [username, [user, stack, active, bet]] of this.user_icons) {
+            if (!this.seats.has(username)) {
+                this.svg.removeChild(user);
+                this.svg.removeChild(stack);
+                this.svg.removeChild(active);
+                this.svg.removeChild(bet);
+                this.user_icons.delete(username);
+            } else {
+                const seat = this.seats.get(username);
+                const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats;
+                const x = 240 - 180 * Math.sin(angle);
+                const y = 240 + 120 * Math.cos(angle) + 60 * (Math.sign(Math.cos(angle)) || 1);
+                user.setAttribute("x", x);
+                user.setAttribute("y", y);
+                stack.childNodes[0].nodeValue = this.stacks.get(username);
+                stack.setAttribute("x", x);
+                stack.setAttribute("y", y + 20);
+                active.classList.toggle("active", this.active === username);
+                active.setAttribute("cx", x - 10);
+                active.setAttribute("cy", y - 5);
+                bet.childNodes[0].nodeValue = this.bets.get(username) || "";
+                bet.setAttribute("x", 240 - 120 * Math.sin(angle));
+                bet.setAttribute("y", 250 + 70 * Math.cos(angle));
+            }
+        }
+        for (const [username, seat] of this.seats) {
+            if (!this.user_icons.has(username)) {
+                const user = document.createElementNS(svgns, "text");
+                const text = document.createTextNode(username);
+                user.append(text);
+                const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats;
+                const x = 240 - 180 * Math.sin(angle);
+                const y = 240 + 120 * Math.cos(angle) + 60 * (Math.sign(Math.cos(angle)) || 1);
+                user.setAttribute("x", x);
+                user.setAttribute("y", y);
+                this.svg.append(user);
+
+                const stack = document.createElementNS(svgns, "text");
+                const stack_text = document.createTextNode(this.stacks.get(username));
+                stack.append(stack_text);
+                stack.setAttribute("x", x);
+                stack.setAttribute("y", y + 20);
+                this.svg.append(stack);
+
+                const active = document.createElementNS(svgns, "circle");
+                active.setAttribute("cx", x - 10);
+                active.setAttribute("cy", y - 5);
+                active.setAttribute("r", 5);
+                active.classList.add("active-indicator");
+                active.classList.toggle("active", this.active === username);
+                this.svg.append(active);
+
+                const bet = document.createElementNS(svgns, "text");
+                const bet_text = document.createTextNode(this.bets.get(username) || "");
+                bet.append(bet_text);
+                bet.setAttribute("x", 240 - 120 * Math.sin(angle));
+                bet.setAttribute("y", 250 + 70 * Math.cos(angle));
+                this.svg.append(bet);
+
+                this.user_icons.set(username, [user, stack, active, bet]);
+            }
+        }
+    }
+
+    redraw_cards() {
+        for (const [username, cards] of this.hands) {
+            const seat = this.seats.get(username);
+            const angle = ((seat - this.my_seat) % this.num_seats) * 2 * Math.PI / this.num_seats;
+            const offset = cards.length * 10;
+            let x = 227.5 + offset - 180 * Math.sin(angle);
+            const y = 210 + 120 * Math.cos(angle);
+            for (const {card, image} of cards) {
+                image.setAttribute("x", x);
+                image.setAttribute("y", y);
+                x -= 20;
+            }
+        }
+        let x = 120.0;
+        for (const {card, image} of this.community) {
+            const y = 210;
+            image.setAttribute("x", x);
+            image.setAttribute("y", y);
+            x += 50;
+        }
+    }
+
+    card_image(username, card) {
+        const image = document.createElementNS(svgns, "image");
+        image.setAttribute("width", "45");
+        image.setAttribute("height", "70");
+        image.setAttribute("href", card_href(card));
+        this.svg.append(image);
+        return image;
+    }
+
+    player_after(username, is_playing) {
+        const in_seat = this.seats.get(username);
+        let [player_after, player_after_seat] = [null, null];
+        for (const [username, seat] of this.seats) {
+            if (is_playing(username) && seat > in_seat && (player_after_seat === null || seat < player_after_seat)) {
+                player_after = username;
+                player_after_seat = seat;
+            }
+        }
+        if (player_after_seat === null) for (const [username, seat] of this.seats) {
+            if (is_playing(username) && (player_after_seat === null || seat < player_after_seat)) {
+                player_after = username;
+                player_after_seat = seat;
+            }
+        }
+        return player_after;
+    }
+
+    chips_to_call() {
+        const max = Math.max(...this.bets.values());
+        const bet = this.bets.get(this.username) || 0;
+        if (max >= 0) {
+            return max - bet;
+        } else {
+            return 0;
+        }
+    }
+
+    min_raise() {
+        if (this.community.length === 0) {
+            return Math.max(this.big_blind, Math.max(...this.bets.values()) - this.big_blind);
+        } else {
+            return Math.max(this.big_blind, ...this.bets.values());
+        }
+    }
+
+    take_action(user_action) {
+        switch (user_action.action.action) {
+            case "Join":
+                this.seats.set(user_action.username, user_action.action.seat);
+                this.stacks.set(user_action.username, user_action.action.chips);
+                this.hands.set(user_action.username, []);
+                this.num_seats = Math.max(...this.seats.values()) + 1;
+                this.my_seat = this.seats.get(this.username) || 0;
+                this.redraw_players();
+                break;
+            case "Leave":
+                this.seats.delete(user_action.username);
+                this.stacks.delete(user_action.username);
+                this.hands.delete(user_action.username);
+                this.bets.delete(user_action.username);
+                this.num_seats = Math.max(...this.seats.values()) + 1;
+                this.my_seat = this.seats.get(this.username) || 0;
+                this.redraw_players();
+                break;
+            case "KnockedOut":
+                this.seats.delete(user_action.username);
+                this.stacks.delete(user_action.username);
+                if (this.hands.has(user_action.username)) {
+                    for (const card of this.hands.get(user_action.username)) {
+                        this.svg.removeChild(card.image);
+                    }
+                }
+                this.hands.delete(user_action.username);
+                this.bets.delete(user_action.username);
+                if (this.active === user_action.username) {
+                    this.active = null;
+                }
+                this.redraw_cards();
+                this.redraw_players();
+                break;
+            case "NextToDeal":
+                this.dealer = user_action.username;
+                for (const card of this.community) {
+                    this.svg.removeChild(card.image);
+                }
+                this.community = [];
+                for (const [username, hand] of this.hands) {
+                    for (const card of hand) {
+                        this.svg.removeChild(card.image);
+                    }
+                }
+                this.hands.clear();
+                this.active = null;
+                this.redraw_players();
+                break;
+            case "Bet":
+            case "PostBlind":
+                const current_bet = this.bets.get(user_action.username) || 0;
+                const current_stack = this.stacks.get(user_action.username);
+                this.bets.set(user_action.username, current_bet + user_action.action.chips);
+                this.stacks.set(user_action.username, current_stack - user_action.action.chips);
+                this.active = this.player_after(user_action.username, username => this.hands.has(username) && this.stacks.get(username) > 0);
+                this.redraw_players();
+                break;
+            case "Fold":
+                for (const card of this.hands.get(user_action.username)) {
+                    this.svg.removeChild(card.image);
+                }
+                this.hands.delete(user_action.username);
+                this.pot += this.bets.get(user_action.username) || 0;
+                this.active = this.player_after(user_action.username, username => this.hands.has(username) && this.stacks.get(username) > 0);
+                this.redraw_players();
+                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.hands.has(user_action.username)) {
+                    this.hands.set(user_action.username, []);
+                }
+                this.hands.get(user_action.username).push(card);
+                this.redraw_cards();
+                break;
+            case "CommunityCard":
+                const community_card = {
+                    card: user_action.action.card,
+                    image: this.card_image(null, user_action.action.card),
+                };
+                this.community.push(community_card);
+                for (const [username, bet] of this.bets) {
+                    this.pot += bet;
+                }
+                this.bets.clear();
+                this.active = this.player_after(user_action.username, username => this.hands.has(username) && this.stacks.get(username) > 0);
+                this.redraw_cards();
+                this.redraw_players();
+                break;
+            case "WinHand":
+                for (const [username, bet] of this.bets) {
+                    this.pot += bet;
+                }
+                this.bets.clear();
+                this.pot -= user_action.action.chips;
+                this.stacks.set(user_action.username, this.stacks.get(user_action.username) + user_action.action.chips);
+                this.redraw_players();
+            case "EndDeal":
+                this.active = this.player_after(user_action.username, username => true);
+                this.redraw_players();
+                break;
+            case "WinGame":
+                console.log(this);
+                break;
+            default:
+                console.error("Unhandled action for knock-out whist", user_action);
+                break;
+        }
+    }
+}
index 27e2ba9d8c79ff450ffc4c485242784428041f46..f4edb7dd28ac2c6aac75cbc818b78213e22fa2c7 100644 (file)
@@ -3,6 +3,7 @@ const svgns = "http://www.w3.org/2000/svg";
 import { GameList } from "./gamelist.js";
 import { Chatroom } from "./chatroom.js";
 import { KnockOutWhist } from "./whist.js";
+import { TexasHoldEm } from "./poker.js";
 
 export class Socket {
     constructor(container, login_all_sockets) {
@@ -113,17 +114,16 @@ export class Socket {
 
     create_game_display(summary, actions) {
         this.container.textContent = "";
+        let Format;
         switch (this.game.summary.settings.format) {
-            case "Chatroom":
-                this.game = new Chatroom(this.container, summary, actions, this.auth.username, message => this.send(message));
-                break;
-            case "KnockOutWhist":
-                this.game = new KnockOutWhist(this.container, summary, actions, this.auth.username, message => this.send(message));
-                break;
+            case "Chatroom": Format = Chatroom; break;
+            case "KnockOutWhist": Format = KnockOutWhist; break;
+            case "TexasHoldEm": Format = TexasHoldEm; break;
             default:
                 console.error("Unknown format: ", this.game.summary.settings.format);
-                break;
+                return;
         }
+        this.game = new Format(this.container, summary, actions, this.auth.username, message => this.send(message));
     }
 
     onclose() {
index 521256000105079b200e964afc69ee33aaa1ad36..b2559271a11fa82718aeb6aea0d8b36d7353ade4 100644 (file)
@@ -1,6 +1,7 @@
 @import url("style/login.css");
 @import url("style/game-list.css");
 @import url("style/chatroom.css");
+@import url("style/poker.css");
 @import url("style/whist.css");
 
 html, body {
diff --git a/site/style/poker.css b/site/style/poker.css
new file mode 100644 (file)
index 0000000..ff6cb75
--- /dev/null
@@ -0,0 +1,26 @@
+.controls {
+    fill: grey;
+    stroke: black;
+    stroke-width: 2px;
+}
+
+.fold-control, .call-control, .bet-control {
+    fill: #808080;
+    stroke: black;
+    stroke-width: 1px;
+    transition: fill 0.5s;
+    pointer-events: none;
+}
+
+.fold-control.active, .call-control.active, .bet-control.active {
+    fill: #8080ff;
+    pointer-events: auto;
+}
+
+.fold-control.active:hover, .call-control.active:hover, .bet-control.active:hover {
+    fill: #a0a0ff;
+}
+
+.fold-control-label, .call-control-label, .bet-control-label {
+    pointer-events: none;
+}
index a06201676ccaacf02b6e80cff513303936620ad7..c30a07b12dab21731a597a2a0f4a9d0fbdeb218f 100644 (file)
@@ -157,10 +157,10 @@ impl ConnectionState {
                 }
             }
             (&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)) => {
+                match (self.server.game_summary(id).await, self.server.game_state(id, 0).await, self.server.game_seed(id).await) {
+                    (Ok(summary), Ok(actions), Ok(seed)) => {
                         let actions_view = actions.iter().map(|action| action.view_for(username)).collect();
-                        let mut game = Game::new(summary.clone());
+                        let mut game = Game::new(summary.clone(), seed);
                         for action in actions {
                             if let Err(err) = game.take_action(action) {
                                 error!("Action from database failed to apply: {}", err);
@@ -169,8 +169,7 @@ impl ConnectionState {
                         self.client = ClientState::LoggedIn{username: username.clone(), state: LoggedInState::InGame{game}};
                         ServerMessage::JoinGameSuccess{summary, actions: actions_view}
                     }
-                    (Err(err), _) => ServerMessage::JoinGameFailure{reason: err.to_string()},
-                    (_, Err(err)) => ServerMessage::JoinGameFailure{reason: err.to_string()},
+                    (Err(err), _, _) | (_, Err(err), _) | (_, _, Err(err)) => ServerMessage::JoinGameFailure{reason: err.to_string()},
                 }
             }
             (&mut ClientState::LoggedIn{username, state: LoggedInState::InGame{ref mut game}}, ClientMessage::TakeAction{action}) => {
index 1a9c90c6143ec26e544e1e16c14b708734355d74..3aa9644016b2f9fa812eeeaa8b1260f4a58e1c76 100644 (file)
@@ -25,7 +25,8 @@ impl Dealer {
         interests.insert(ClientInterest::Game{id});
         server.register_interests(interests).await;
         let summary = server.game_summary(id).await?;
-        let mut game = Game::new(summary);
+        let seed = server.game_seed(id).await?;
+        let mut game = Game::new(summary, seed);
         let mut dealer = Dealer{server, dealer: DealerState{game}};
         dealer.retrieve_updates().await?;
         Ok(dealer)
index a5d66ae50a0199b2b287739801501a0a1a84a1ba..890347f7f90393d6a967e9a133bdb0c4cb6516b7 100644 (file)
@@ -3,13 +3,13 @@ use std::fmt::{Debug, Display, Formatter};
 use crate::card::{Card, Suit};
 use crate::username::Username;
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct UserAction {
     pub username: Username,
     pub action: Action,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(transparent)]
 pub struct ValidatedUserAction(pub UserAction);
 
@@ -22,7 +22,7 @@ impl ValidatedUserAction {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "action")]
 pub enum Action {
     Join { seat: u32, chips: u64 },
@@ -36,9 +36,10 @@ pub enum Action {
     ChooseTrumps { suit: Suit },
     Fold,
     Bet { chips: u64 },
+    PostBlind { chips: u64 },
     WinTrick,
     WinCall,
-    WinHand { chips: u64 },
+    WinHand { chips: u64, hand: Option<String> },
     WinGame,
     Message { message: String },
     Leave,
index 9297388e606e75a5b39046f4c39f929d1a1c9542..6e459354b46fbab6557bfe3c8914bda3d83c3732 100644 (file)
@@ -6,6 +6,7 @@ mod whist;
 use std::collections::HashSet;
 use std::fmt::{Debug, Display, Formatter};
 
+use crate::rng::Seed;
 use crate::username::Username;
 
 use self::chatroom::{Chatroom, ChatroomSettings};
@@ -40,11 +41,11 @@ impl Clone for Box<dyn Game> {
 }
 
 impl dyn Game {
-    pub fn new(GameSummary{id, settings}: GameSummary) -> Box<Self> {
+    pub fn new(GameSummary{id, settings}: GameSummary, seed: Seed) -> Box<Self> {
         match settings {
             GameSettings::Chatroom(settings) => Box::new(Chatroom::new(id, settings)),
             GameSettings::KnockOutWhist(settings) => Box::new(KnockOutWhist::new(id, settings)),
-            GameSettings::TexasHoldEm(settings) => Box::new(TexasHoldEm::new(id, settings)),
+            GameSettings::TexasHoldEm(settings) => Box::new(TexasHoldEm::new(id, settings, seed)),
         }
     }
 }
index 9879147f99021fb52a1e6995fde6feb0956f59ac..98afa92fdc6aa556e971eed3b4a64b29652e63aa 100644 (file)
@@ -3,12 +3,13 @@ use std::convert::TryInto;
 
 use itertools::Itertools;
 use rand::seq::IteratorRandom;
-use rand::thread_rng;
+use rand::Rng;
 
 use crate::card::{Card, Suit, FIFTY_TWO_CARD_DECK};
 use crate::seats::Seats;
 use crate::username::Username;
 use crate::util::max::IteratorMaxItems;
+use crate::rng::{Seed, WaveRng};
 
 use super::super::{Action, ActionError, Game, UserAction, ValidatedUserAction};
 
@@ -43,6 +44,7 @@ pub struct TexasHoldEmSettings {
 pub struct TexasHoldEm {
     id: i64,
     settings: TexasHoldEmSettings,
+    rng: WaveRng,
     actions_len: usize,
     state: State,
     seats: Seats,
@@ -54,6 +56,7 @@ pub struct TexasHoldEm {
     receiver: Option<Username>,
     active: Option<Username>,
     bets: HashMap<Username, u64>,
+    committed: HashMap<Username, u64>,
     players: HashSet<Username>,
     pot: u64,
     small_blind: u64,
@@ -61,12 +64,13 @@ pub struct TexasHoldEm {
 }
 
 impl TexasHoldEm {
-    pub fn new(id: i64, settings: TexasHoldEmSettings) -> Self {
+    pub fn new(id: i64, settings: TexasHoldEmSettings, seed: Seed) -> Self {
         let small_blind = settings.small_blind;
         let big_blind = small_blind * 2;
         Self {
             id,
             settings,
+            rng: seed.rng(),
             actions_len: 0,
             state: State::NotStarted,
             seats: Seats::new(),
@@ -78,6 +82,7 @@ impl TexasHoldEm {
             receiver: None,
             active: None,
             bets: HashMap::new(),
+            committed: HashMap::new(),
             players: HashSet::new(),
             pot: 0,
             small_blind,
@@ -86,7 +91,20 @@ impl TexasHoldEm {
     }
 
     fn chips_to_call(&self, username: Username) -> u64 {
-        todo!()
+        match (self.bets.values().max(), self.bets.get(&username)) {
+            (Some(&max), Some(&bet)) => max - bet,
+            (Some(&max), None) => max,
+            (None, Some(_)) | (None, None) => 0,
+        }
+    }
+
+    fn min_raise(&self) -> u64 {
+        match (self.state, self.bets.values().max()) {
+            (State::PreFlopBetting, Some(&max)) if max < self.big_blind * 2 => self.big_blind,
+            (State::PreFlopBetting, Some(&max)) => max - self.big_blind,
+            (_, Some(&max)) => max,
+            (_, None) => self.big_blind,
+        }
     }
 
     fn stack(&self, username: Username) -> u64 {
@@ -95,6 +113,21 @@ impl TexasHoldEm {
             None => 0,
         }
     }
+
+    fn max_winnings(&self, username: Username) -> u64 {
+        match self.committed.get(&username) {
+            Some(&committed) => self.committed.values().map(|&value| value.min(committed)).sum(),
+            None => 0,
+        }
+    }
+
+    fn is_able_to_bet(&self, username: Username) -> bool {
+        self.players.contains(&username) && !matches!(self.stacks.get(&username), Some(&0) | None)
+    }
+
+    fn players_able_to_bet(&self) -> usize {
+        self.players.iter().map(|&username| self.is_able_to_bet(username)).count()
+    }
 }
 
 impl Game for TexasHoldEm {
@@ -149,16 +182,31 @@ impl Game for TexasHoldEm {
             (_, Action::Fold) | (_, Action::Bet{..}) if self.active != Some(username) => Err(ActionError::OutOfTurn),
             (_, Action::Fold) if self.chips_to_call(username) == 0 => Err(ActionError::CannotFold),
             (_, Action::Fold) => Ok(ValidatedUserAction(UserAction{username, action: Action::Fold})),
-            (_, Action::Bet{chips}) if chips > self.stack(username) => Err(ActionError::NotEnoughChips),
-            (_, Action::Bet{chips}) if chips < self.chips_to_call(username) => Err(ActionError::BetSizeTooSmall),
-            (_, Action::Bet{chips}) if chips > self.chips_to_call(username) && chips < 2 * self.chips_to_call(username) => Err(ActionError::BetSizeTooSmall),
-            (_, Action::Bet{chips}) => Ok(ValidatedUserAction(UserAction{username, action: Action::Bet{chips}})),
+            (_, Action::Bet{chips}) => {
+                let stack = self.stack(username);
+                if chips > stack {
+                    Err(ActionError::NotEnoughChips)
+                } else if chips == stack {
+                    Ok(ValidatedUserAction(UserAction{username, action: Action::Bet{chips}}))
+                } else {
+                    let to_call = self.chips_to_call(username);
+                    let min_raise = self.min_raise();
+                    if chips < to_call {
+                        Err(ActionError::BetSizeTooSmall)
+                    } else if chips > to_call && chips < to_call + min_raise {
+                        Err(ActionError::BetSizeTooSmall)
+                    } else {
+                        Ok(ValidatedUserAction(UserAction{username, action: Action::Bet{chips}}))
+                    }
+                }
+            }
             (_, _) => Err(ActionError::InvalidActionForGameType),
         }
     }
 
     fn take_action(&mut self, ValidatedUserAction(UserAction{username, action}): ValidatedUserAction) -> Result<(), ActionError> {
         self.actions_len += 1;
+        self.rng.advance();
         match (self.state, action) {
             (_, Action::PlayCard{..}) | (_, Action::ChooseTrumps{..}) => {
                 Err(ActionError::InvalidActionForGameType)
@@ -180,10 +228,12 @@ impl Game for TexasHoldEm {
                 self.active = None;
                 self.players = self.seats.player_set();
                 self.bets.clear();
+                self.committed.clear();
                 if self.pot != 0 {
                     error!("Logic error: pot was {} upon dealing: {:#?}", self.pot, self);
                     self.pot = 0;
                 }
+                self.state = State::Dealing;
                 Ok(())
             }
             (State::Dealing, Action::ReceiveCard{card: Some(card)}) => {
@@ -201,21 +251,27 @@ impl Game for TexasHoldEm {
                 self.receiver = None;
                 Ok(())
             }
-            (State::PostingSmallBlind, Action::Bet{chips}) => {
+            (State::PostingSmallBlind, Action::PostBlind{chips}) => {
                 *self.bets.entry(username).or_default() += chips;
+                *self.committed.entry(username).or_default() += chips;
                 *self.stacks.entry(username).or_default() -= chips;
                 self.state = State::PostingBigBlind;
                 Ok(())
             }
-            (State::PostingBigBlind, Action::Bet{chips}) => {
+            (State::PostingBigBlind, Action::PostBlind{chips}) => {
                 *self.bets.entry(username).or_default() += chips;
+                *self.committed.entry(username).or_default() += chips;
                 *self.stacks.entry(username).or_default() -= chips;
-                self.active = self.seats.player_after_in(username, &self.players);
+                self.active = self.seats.player_after_where(username, |username| self.players.contains(&username));
                 self.state = State::PreFlopBetting;
                 Ok(())
             }
             (_, Action::Bet{chips}) => {
-                if chips == 0 && self.bets.len() == self.players.len() && self.bets.values().all_equal() {
+                *self.bets.entry(username).or_default() += chips;
+                *self.committed.entry(username).or_default() += chips;
+                *self.stacks.entry(username).or_default() -= chips;
+                if (chips == 0 || !matches!(self.state, State::PreFlopBetting) || self.bets.values().max() > Some(&self.big_blind)) && self.bets.len() == self.players_able_to_bet() && self.bets.values().all_equal()
+                {
                     self.active = None;
                     self.pot += self.bets.values().sum::<u64>();
                     self.bets.clear();
@@ -230,9 +286,7 @@ impl Game for TexasHoldEm {
                         }
                     };
                 } else {
-                    *self.bets.entry(username).or_default() += chips;
-                    *self.stacks.entry(username).or_default() -= chips;
-                    self.active = self.seats.player_after_in(username, &self.players);
+                    self.active = self.seats.player_after_where(username, |username| self.is_able_to_bet(username));
                 }
                 Ok(())
             }
@@ -241,31 +295,52 @@ impl Game for TexasHoldEm {
                 self.bets.remove(&username);
                 self.players.remove(&username);
                 self.hands.remove(&username);
-                self.active = self.seats.player_after_in(username, &self.players);
+                if self.players_able_to_bet() == 1 {
+                    self.pot += self.bets.values().sum::<u64>();
+                    self.bets.clear();
+                    self.active = None;
+                } else {
+                    self.active = self.seats.player_after_where(username, |username| self.is_able_to_bet(username));
+                }
                 Ok(())
             }
             (State::DealingFlop, Action::CommunityCard{card}) => {
                 self.community.insert(card);
+                self.deck.remove(&card);
                 if self.community.len() == 3 {
-                    self.active = self.seats.player_after_in(username, &self.players);
-                    self.state = State::PostFlopBetting;
+                    self.active = self.seats.player_after_where(username, |username| self.is_able_to_bet(username));
+                    self.state = match self.active {
+                        Some(_) => State::PostFlopBetting,
+                        None => State::DealingTurn,
+                    };
                 }
                 Ok(())
             }
             (State::DealingTurn, Action::CommunityCard{card}) => {
                 self.community.insert(card);
-                self.active = self.seats.player_after_in(username, &self.players);
-                self.state = State::TurnBetting;
+                self.deck.remove(&card);
+                self.active = self.seats.player_after_where(username, |username| self.is_able_to_bet(username));
+                self.state = match self.active {
+                    Some(_) => State::TurnBetting,
+                    None => State::DealingRiver,
+                };
                 Ok(())
             }
             (State::DealingRiver, Action::CommunityCard{card}) => {
-                self.active = self.seats.player_after_in(username, &self.players);
-                self.state = State::RiverBetting;
+                self.community.insert(card);
+                self.deck.remove(&card);
+                self.active = self.seats.player_after_where(username, |username| self.is_able_to_bet(username));
+                self.state = match self.active {
+                    Some(_) => State::RiverBetting,
+                    None => State::Showdown,
+                };
                 Ok(())
             }
-            (_, Action::WinHand{chips}) if chips <= self.pot => {
+            (_, Action::WinHand{chips, ..}) if chips <= self.pot => {
                 self.pot -= chips;
                 *self.stacks.entry(username).or_default() += chips;
+                self.hands.remove(&username);
+                self.players.remove(&username);
                 Ok(())
             }
             (_, Action::WinGame) => {
@@ -278,11 +353,11 @@ impl Game for TexasHoldEm {
     }
 
     fn next_dealer_action(&self) -> Option<ValidatedUserAction> {
-        let mut rng = thread_rng();
+        let mut rng = self.rng.clone();
         match self.state {
             State::NotStarted => {
                 if self.seats.players_len() == self.settings.max_players as usize { // TODO
-                    if let Some(username) = self.seats.player_set().into_iter().choose(&mut rng) {
+                    if let Some(username) = rng.choose_from(self.seats.player_set()) {
                         return Some(ValidatedUserAction(UserAction{username, action: Action::NextToDeal}));
                     }
                 }
@@ -290,34 +365,56 @@ impl Game for TexasHoldEm {
             }
             State::Dealing => {
                 if let Some(username) = self.receiver {
-                    let card = self.deck.iter().choose(&mut rng).cloned();
+                    let card = rng.choose_from(&self.deck).cloned();
                     Some(ValidatedUserAction(UserAction{username, action: Action::ReceiveCard{card}}))
+                } else if let Some(username) = self.dealer {
+                    Some(ValidatedUserAction(UserAction{username, action: Action::EndDeal}))
                 } else {
                     None
                 }
             }
+            State::PostingSmallBlind if self.seats.players_len() == 2 => {
+                self.dealer
+                    .map(|username| {
+                        let chips = self.stack(username).min(self.small_blind);
+                        ValidatedUserAction(UserAction{username, action: Action::PostBlind{chips}})
+                    })
+            }
             State::PostingSmallBlind => {
                 self.dealer.and_then(|dealer| self.seats.player_after(dealer))
                     .map(|username| {
                         let chips = self.stack(username).min(self.small_blind);
-                        ValidatedUserAction(UserAction{username, action: Action::Bet{chips}})
+                        ValidatedUserAction(UserAction{username, action: Action::PostBlind{chips}})
+                    })
+            }
+            State::PostingBigBlind if self.seats.players_len() == 2 => {
+                self.dealer.and_then(|dealer| self.seats.player_after(dealer))
+                    .map(|username| {
+                        let chips = self.stack(username).min(self.small_blind * 2);
+                        ValidatedUserAction(UserAction{username, action: Action::PostBlind{chips}})
                     })
             }
             State::PostingBigBlind => {
                 self.dealer.and_then(|dealer| self.seats.player_after(dealer))
                     .and_then(|small_blind| self.seats.player_after(small_blind))
                     .map(|username| {
-                        let chips = self.stack(username).min(self.small_blind);
-                        ValidatedUserAction(UserAction{username, action: Action::Bet{chips}})
+                        let chips = self.stack(username).min(self.small_blind * 2);
+                        ValidatedUserAction(UserAction{username, action: Action::PostBlind{chips}})
                     })
             }
             State::PreFlopBetting | State::PostFlopBetting | State::TurnBetting | State::RiverBetting => {
-                if self.players.len() == 1 {
+                if self.players.len() <= 1 {
                     if self.pot > 0 {
                         self.players.iter().next()
-                            .map(|&username| ValidatedUserAction(UserAction{username, action: Action::WinHand{chips: self.pot}}))
+                            .map(|&username| ValidatedUserAction(UserAction {
+                                username,
+                                action: Action::WinHand {
+                                    chips: self.pot,
+                                    hand: None,
+                                }
+                            }))
                     } else if self.seats.players_len() == 1 {
-                        self.players.iter().next()
+                        self.seats.player_set().iter().next()
                             .map(|&username| ValidatedUserAction(UserAction{username, action: Action::WinGame}))
                     } else if let Some((&username, _)) = self.stacks.iter().find(|&(_, &stack)| stack == 0) {
                         Some(ValidatedUserAction(UserAction{username, action: Action::KnockedOut}))
@@ -333,12 +430,15 @@ impl Game for TexasHoldEm {
             }
             State::DealingFlop | State::DealingTurn | State::DealingRiver => {
                 self.dealer.and_then(|username|
-                    self.deck.iter().choose(&mut rng).map(|&card|
+                    rng.choose_from(&self.deck).map(|&card|
                         ValidatedUserAction(UserAction{username, action: Action::CommunityCard{card}})))
             }
             State::Showdown if self.pot == 0 => {
                 if let Some((&username, _)) = self.stacks.iter().find(|&(_, &stack)| stack == 0) {
                     Some(ValidatedUserAction(UserAction{username, action: Action::KnockedOut}))
+                } else if self.seats.players_len() == 1 {
+                    self.seats.player_set().iter().next()
+                        .map(|&username| ValidatedUserAction(UserAction{username, action: Action::WinGame}))
                 } else if let Some(username) = self.dealer.and_then(|dealer| self.seats.player_after(dealer)) {
                     Some(ValidatedUserAction(UserAction{username, action: Action::NextToDeal}))
                 } else {
@@ -351,10 +451,88 @@ impl Game for TexasHoldEm {
                     .map(|(&username, hand)| (username, hand.iter().chain(self.community.iter()).cloned().collect::<Vec<_>>()))
                     .filter_map(|(username, cards)| cards.try_into().ok().map(rank_7_card_hand).map(|hand| (username, hand)))
                     .max_items_by_key(|(_, hand)| *hand);
+                info!("Showdown: community: {:?}", self.community);
+                info!("Showdown: all hands: {:?}", self.hands);
+                info!("Showdown: winning hands: {:?}", winning_hands);
                 winning_hands.first()
-                    .map(|&(username, _)| ValidatedUserAction(UserAction{username, action: Action::WinHand{chips: self.pot / winning_hands.len() as u64}}))
+                    .map(|&(username, hand)| ValidatedUserAction(UserAction {
+                        username,
+                        action: Action::WinHand {
+                            chips: (self.pot / winning_hands.len() as u64).min(self.max_winnings(username)),
+                            hand: Some(hand.to_string()),
+                        }
+                    }))
             }
             State::Completed => None,
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn simple_heads_up_with_2_hands() {
+        let json = r#"[
+            {"username":"p1","action":{"action":"Join","seat":0,"chips":1000}},
+            {"username":"p2","action":{"action":"Join","seat":1,"chips":1000}},
+            {"username":"p1","action":{"action":"NextToDeal"}},
+            {"username":"p2","action":{"action":"ReceiveCard","card":{"rank":"Nine","suit":"Clubs"}}},
+            {"username":"p1","action":{"action":"ReceiveCard","card":{"rank":"Nine","suit":"Spades"}}},
+            {"username":"p2","action":{"action":"ReceiveCard","card":{"rank":"Ace","suit":"Diamonds"}}},
+            {"username":"p1","action":{"action":"ReceiveCard","card":{"rank":"Six","suit":"Diamonds"}}},
+            {"username":"p1","action":{"action":"EndDeal"}},
+            {"username":"p1","action":{"action":"PostBlind","chips":25}},
+            {"username":"p2","action":{"action":"PostBlind","chips":50}},
+            {"username":"p1","action":{"action":"Fold"}},
+            {"username":"p2","action":{"action":"WinHand","chips":75,"hand":null}},
+            {"username":"p2","action":{"action":"NextToDeal"}},
+            {"username":"p1","action":{"action":"ReceiveCard","card":{"rank":"Four","suit":"Clubs"}}},
+            {"username":"p2","action":{"action":"ReceiveCard","card":{"rank":"King","suit":"Clubs"}}},
+            {"username":"p1","action":{"action":"ReceiveCard","card":{"rank":"Nine","suit":"Hearts"}}},
+            {"username":"p2","action":{"action":"ReceiveCard","card":{"rank":"Jack","suit":"Diamonds"}}},
+            {"username":"p2","action":{"action":"EndDeal"}},
+            {"username":"p2","action":{"action":"PostBlind","chips":25}},
+            {"username":"p1","action":{"action":"PostBlind","chips":50}},
+            {"username":"p2","action":{"action":"Bet","chips":25}},
+            {"username":"p1","action":{"action":"Bet","chips":925}},
+            {"username":"p2","action":{"action":"Bet","chips":925}},
+            {"username":"p2","action":{"action":"CommunityCard","card":{"rank":"Eight","suit":"Hearts"}}},
+            {"username":"p2","action":{"action":"CommunityCard","card":{"rank":"Four","suit":"Diamonds"}}},
+            {"username":"p2","action":{"action":"CommunityCard","card":{"rank":"Eight","suit":"Spades"}}},
+            {"username":"p2","action":{"action":"CommunityCard","card":{"rank":"Ace","suit":"Hearts"}}},
+            {"username":"p2","action":{"action":"CommunityCard","card":{"rank":"Jack","suit":"Hearts"}}},
+            {"username":"p2","action":{"action":"WinHand","chips":1950,"hand":"Two Pair, Js & 8s, A Kicker"}},
+            {"username":"p1","action":{"action":"KnockedOut"}},
+            {"username":"p2","action":{"action":"WinGame"}}
+        ]"#;
+
+        let settings = TexasHoldEmSettings {
+            title: "2-Player TexasHoldEm Test".to_string(),
+            max_players: 2,
+            small_blind: 25,
+            starting_stack: 1000,
+        };
+
+        let seed_json = r#"{"rng":"ChaCha20","seed":"f05dc83bdce966e72a3a81b19ccded2e70387eb68deacf60ed8de1ee78b9ff0e"}"#;
+        let seed = serde_json::from_str(seed_json).unwrap();
+
+        let mut game = TexasHoldEm::new(0, settings, seed);
+        let actions: Vec<UserAction> = serde_json::from_str(json).unwrap();
+        for action in actions {
+            match action.action {
+                Action::Join{..} | Action::Bet{..} | Action::Fold => {
+                    let validated = game.validate_action(action.clone()).unwrap();
+                    assert_eq!(ValidatedUserAction(action), validated);
+                    game.take_action(validated).unwrap();
+                }
+                _ => {
+                    let dealer_action = game.next_dealer_action().unwrap();
+                    assert_eq!(ValidatedUserAction(action), dealer_action);
+                    game.take_action(dealer_action).unwrap();
+                }
+            }
+        }
+    }
+}
index da82f69f2483c81b5131d86b98016ef8132cbfb0..b2d11a368f2c89185d780fa7d32e9fcef88f09b0 100644 (file)
@@ -247,7 +247,7 @@ impl Game for KnockOutWhist {
                     if self.winners.len() == 1 {
                         self.call = self.winners.iter().next().cloned();
                     } else {
-                        self.receiver = self.dealer.and_then(|dealer| self.seats.player_after_in(dealer, &self.winners));
+                        self.receiver = self.dealer.and_then(|dealer| self.seats.player_after_where(dealer, |username| self.winners.contains(&username)));
                         self.deck = FIFTY_TWO_CARD_DECK.iter().cloned().collect();
                         self.hands.clear();
                         self.trump_card = None;
index 00cbf24f42a8c6a95078ec312a918c10439f5f45..4d558631180ad9fa1ffcf6b140c0d8ab3f1251ce 100644 (file)
@@ -22,6 +22,7 @@ mod client;
 mod dealer;
 mod game;
 mod hands;
+mod rng;
 mod seats;
 mod server;
 mod username;
diff --git a/src/rng.rs b/src/rng.rs
new file mode 100644 (file)
index 0000000..113503a
--- /dev/null
@@ -0,0 +1,127 @@
+use std::cmp::{Ord, Eq};
+
+use getrandom::getrandom;
+use itertools::Itertools;
+use rand::{SeedableRng, CryptoRng, RngCore, Rng, Error, seq::IteratorRandom};
+use rand_chacha::ChaCha20Rng;
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
+#[serde(tag = "rng")]
+pub enum Seed {
+    ChaCha20 {
+        #[serde(with = "hex")]
+        seed: [u8; 32]
+    },
+}
+
+impl Seed {
+    pub fn cha_cha_20_from_entropy() -> Self {
+        let mut seed = [0u8; 32];
+        getrandom(&mut seed).expect("getrandom failed when generating random seed");
+        Self::ChaCha20{seed}
+    }
+
+    pub fn rng(self) -> WaveRng {
+        match self {
+            Seed::ChaCha20{seed} => {
+                WaveRng::ChaCha20(ChaCha20Rng::from_seed(seed))
+            }
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum WaveRng {
+    ChaCha20(ChaCha20Rng),
+}
+
+impl WaveRng {
+    pub fn advance(&mut self) {
+        let _: [u8; 32] = match self {
+            WaveRng::ChaCha20(rng) => rng.gen(),
+        };
+    }
+
+    pub fn choose_from<T: Eq + Ord, I: IntoIterator<Item=T>>(&mut self, elements: I) -> Option<T> {
+        elements.into_iter().sorted().choose_stable(self)
+    }
+}
+
+impl RngCore for WaveRng {
+    fn next_u32(&mut self) -> u32 {
+        match self {
+            WaveRng::ChaCha20(rng) => rng.next_u32(),
+        }
+    }
+
+    fn next_u64(&mut self) -> u64 {
+        match self {
+            WaveRng::ChaCha20(rng) => rng.next_u64(),
+        }
+    }
+
+    fn fill_bytes(&mut self, dest: &mut [u8]) {
+        match self {
+            WaveRng::ChaCha20(rng) => rng.fill_bytes(dest),
+        }
+    }
+
+    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> {
+        match self {
+            WaveRng::ChaCha20(rng) => rng.try_fill_bytes(dest),
+        }
+    }
+}
+
+impl CryptoRng for WaveRng {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use std::collections::HashSet;
+
+    use rand::Rng;
+
+    #[test]
+    fn chacha_true_values() {
+        let seed_json = r#"{"rng":"ChaCha20","seed":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
+        let seed: Seed = serde_json::from_str(seed_json).unwrap();
+        let mut rng = seed.rng();
+
+        let mut results = [0u32; 16];
+        for i in results.iter_mut() {
+            *i = rng.next_u32();
+        }
+        let expected = [
+            0xade0b876, 0x903df1a0, 0xe56a5d40, 0x28bd8653, 0xb819d2bd, 0x1aed8da0, 0xccef36a8,
+            0xc70d778b, 0x7c5941da, 0x8d485751, 0x3fe02477, 0x374ad8b8, 0xf4b8436a, 0x1ca11815,
+            0x69b687c3, 0x8665eeb2,
+        ];
+        assert_eq!(results, expected);
+
+        for i in results.iter_mut() {
+            *i = rng.next_u32();
+        }
+        let expected = [
+            0xbee7079f, 0x7a385155, 0x7c97ba98, 0x0d082d73, 0xa0290fcb, 0x6965e348, 0x3e53c612,
+            0xed7aee32, 0x7621b729, 0x434ee69c, 0xb03371d5, 0xd539d874, 0x281fed31, 0x45fb0a51,
+            0x1f0ae1ac, 0x6f4d794b,
+        ];
+        assert_eq!(results, expected);
+    }
+
+    #[test]
+    fn stable_set_values() {
+        let seed_json = r#"{"rng":"ChaCha20","seed":"97a7316097f988e8f3e3d17cc085c9ebe2c132c2aa5310feae73fe11c93f28a8"}"#;
+        let seed: Seed = serde_json::from_str(seed_json).unwrap();
+        let mut rng = seed.rng();
+
+        let mut set = HashSet::new();
+        for i in 0..100 {
+            set.insert(i);
+        }
+
+        assert_eq!(Some(44), rng.choose_from(set));
+    }
+}
index f7a0b8c9dea5f0146756591ddf23eb9d7ef6413a..5a5047ce65f233a1f1d96a2f49501d05db931508 100644 (file)
@@ -43,16 +43,16 @@ impl Seats {
         }
     }
 
-    pub fn player_after_in(&self, username: Username, set: &HashSet<Username>) -> Option<Username> {
+    pub fn player_after_where(&self, username: Username, condition: impl Fn(Username) -> bool) -> Option<Username> {
         for (&seat, &player) in &self.players {
             if player == username {
                 for (_, &name) in self.players.range(seat+1..) {
-                    if set.contains(&name) {
+                    if condition(name) {
                         return Some(name);
                     }
                 }
                 for (_, &name) in self.players.range(..seat) {
-                    if set.contains(&name) {
+                    if condition(name) {
                         return Some(name);
                     }
                 }
index e4ca70fca6b6011b26e2cc531b6b20f1ec8441ff..7ff323485aa5e50d33ec0465bbb5ce029458f53d 100644 (file)
@@ -8,6 +8,7 @@ use serde::{Serialize, Deserialize};
 use crate::auth::Auth;
 use crate::client::ClientInterest;
 use crate::game::{GameList, GameSettings, GameSummary, UserAction, ValidatedUserAction};
+use crate::rng::Seed;
 use crate::username::Username;
 
 #[derive(Clone)]
@@ -117,6 +118,10 @@ fn game_settings_key(id: i64) -> String {
     format!("game:{}:settings", id)
 }
 
+fn game_seed_key(id: i64) -> String {
+    format!("game:{}:seed", id)
+}
+
 fn game_actions_key(id: i64) -> String {
     format!("game:{}:actions", id)
 }
@@ -154,8 +159,8 @@ impl ServerState {
 
     pub async fn create_game(&mut self, settings: GameSettings) -> RedisResult<i64> {
         let id = self.redis.incr("game:next_id", 1).await?;
-        let key = game_settings_key(id);
-        let () = self.redis.set(key, AsJson(settings)).await?;
+        let () = self.redis.set(game_settings_key(id), AsJson(settings)).await?;
+        let () = self.redis.set(game_seed_key(id), AsJson(Seed::cha_cha_20_from_entropy())).await?;
         let () = self.redis.rpush("game:list", id).await?;
         Ok(id)
     }
@@ -181,9 +186,16 @@ impl ServerState {
 
     pub async fn game_summary(&mut self, id: i64) -> RedisResult<GameSummary> {
         let key = game_settings_key(id);
+        info!("Getting summary from key: {}", key);
         self.redis.get(key).await.map(AsJson::get).map(|settings| GameSummary::new(id, settings))
     }
 
+    pub async fn game_seed(&mut self, id: i64) -> RedisResult<Seed> {
+        let key = game_seed_key(id);
+        info!("Getting seed from key: {}", key);
+        self.redis.get(key).await.map(AsJson::get)
+    }
+
     pub async fn take_action(&mut self, id: i64, len: usize, action: &ValidatedUserAction) -> RedisResult<ActionStatus> {
         let key = game_actions_key(id);
         debug!("take_action: EVAL {{TAKE_ACTION_LUA_SCRIPT}} 1 {} {} {:?}", key, len, action);
@@ -227,11 +239,14 @@ impl<T> FromRedisValue for AsJson<T>
     fn from_redis_value(value: &Value) -> RedisResult<Self> {
         match value {
             Value::Data(ref bytes) => serde_json::from_slice(bytes).map(AsJson)
-                .map_err(|err| RedisError::from((
-                    ErrorKind::TypeError,
-                    "Failed to parse as JSON",
-                    err.to_string()
-                ))),
+                .map_err(|err| {
+                    error!("Failed to parse JSON from redis: {}", err);
+                    RedisError::from((
+                        ErrorKind::TypeError,
+                        "Failed to parse as JSON",
+                        err.to_string()
+                    ))
+                }),
             _ => Err(RedisError::from((ErrorKind::TypeError, "Value was not Value::Data"))),
         }
     }