From eb4532abf0a57e06ab25fa817e536b66dd995624 Mon Sep 17 00:00:00 2001 From: Geoffrey Allott Date: Mon, 1 Mar 2021 21:14:23 +0000 Subject: [PATCH] add deterministic rng --- Cargo.lock | 98 ++++------ Cargo.toml | 4 +- site/modules/poker.js | 375 +++++++++++++++++++++++++++++++++++++++ site/modules/socket.js | 14 +- site/style.css | 1 + site/style/poker.css | 26 +++ src/client.rs | 9 +- src/dealer.rs | 3 +- src/game/action.rs | 9 +- src/game/mod.rs | 5 +- src/game/poker/holdem.rs | 244 +++++++++++++++++++++---- src/game/whist.rs | 2 +- src/main.rs | 1 + src/rng.rs | 127 +++++++++++++ src/seats.rs | 6 +- src/server.rs | 29 ++- 16 files changed, 827 insertions(+), 126 deletions(-) create mode 100644 site/modules/poker.js create mode 100644 site/style/poker.css create mode 100644 src/rng.rs diff --git a/Cargo.lock b/Cargo.lock index c350a6c..25cc1de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index a412efd..6dadf4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 index 0000000..68892cc --- /dev/null +++ b/site/modules/poker.js @@ -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; + } + } +} diff --git a/site/modules/socket.js b/site/modules/socket.js index 27e2ba9..f4edb7d 100644 --- a/site/modules/socket.js +++ b/site/modules/socket.js @@ -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() { diff --git a/site/style.css b/site/style.css index 5212560..b255927 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/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 index 0000000..ff6cb75 --- /dev/null +++ b/site/style/poker.css @@ -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; +} diff --git a/src/client.rs b/src/client.rs index a062016..c30a07b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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}) => { diff --git a/src/dealer.rs b/src/dealer.rs index 1a9c90c..3aa9644 100644 --- a/src/dealer.rs +++ b/src/dealer.rs @@ -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) diff --git a/src/game/action.rs b/src/game/action.rs index a5d66ae..890347f 100644 --- a/src/game/action.rs +++ b/src/game/action.rs @@ -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 }, WinGame, Message { message: String }, Leave, diff --git a/src/game/mod.rs b/src/game/mod.rs index 9297388..6e45935 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -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 { } impl dyn Game { - pub fn new(GameSummary{id, settings}: GameSummary) -> Box { + pub fn new(GameSummary{id, settings}: GameSummary, seed: Seed) -> Box { 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)), } } } diff --git a/src/game/poker/holdem.rs b/src/game/poker/holdem.rs index 9879147..98afa92 100644 --- a/src/game/poker/holdem.rs +++ b/src/game/poker/holdem.rs @@ -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, active: Option, bets: HashMap, + committed: HashMap, players: HashSet, 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::(); 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::(); + 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 { - 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::>())) .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 = 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(); + } + } + } + } +} diff --git a/src/game/whist.rs b/src/game/whist.rs index da82f69..b2d11a3 100644 --- a/src/game/whist.rs +++ b/src/game/whist.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 00cbf24..4d55863 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 index 0000000..113503a --- /dev/null +++ b/src/rng.rs @@ -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>(&mut self, elements: I) -> Option { + 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)); + } +} diff --git a/src/seats.rs b/src/seats.rs index f7a0b8c..5a5047c 100644 --- a/src/seats.rs +++ b/src/seats.rs @@ -43,16 +43,16 @@ impl Seats { } } - pub fn player_after_in(&self, username: Username, set: &HashSet) -> Option { + pub fn player_after_where(&self, username: Username, condition: impl Fn(Username) -> bool) -> Option { 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); } } diff --git a/src/server.rs b/src/server.rs index e4ca70f..7ff3234 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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 { 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 { 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 { + 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 { let key = game_actions_key(id); debug!("take_action: EVAL {{TAKE_ACTION_LUA_SCRIPT}} 1 {} {} {:?}", key, len, action); @@ -227,11 +239,14 @@ impl FromRedisValue for AsJson fn from_redis_value(value: &Value) -> RedisResult { 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"))), } } -- 2.34.1