From 61d255083250ef917b672a182a2d604e30544c29 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Thu, 30 Jul 2015 22:57:30 -0700 Subject: [PATCH 01/16] Write some tests for extracting betting actions from text and parsing bet amounts. --- tests/player-interaction-spec.js | 130 +++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 24 deletions(-) diff --git a/tests/player-interaction-spec.js b/tests/player-interaction-spec.js index 3dfa406..ce62529 100644 --- a/tests/player-interaction-spec.js +++ b/tests/player-interaction-spec.js @@ -13,87 +13,169 @@ describe('PlayerInteraction', function() { // time passing using the `HistoricalScheduler`. beforeEach(function() { messages = new rx.Subject(); - channel = { - send: function() { + channel = { + send: function() { // NB: Posting a message to the channel returns an editable message // object, which we're faking out here. return { updateMessage: function() { } }; - } + } }; scheduler = new rx.HistoricalScheduler(); players = []; }); - + it("should add players when they respond with 'yes'", function() { PlayerInteraction.pollPotentialPlayers(messages, channel, scheduler) .subscribe(function(userId) { players.push(userId); }); - - messages.onNext({ + + messages.onNext({ user: 'Phil Hellmuth', text: '*Characteristic whining* But yes.' }); - + messages.onNext({ user: 'Daniel Negreanu', text: 'Absolutely not.' }); - + messages.onNext({ user: 'Dan Harrington', text: 'Yes, count me in.' }); - + assert(players.length === 2); assert(players[0] === 'Phil Hellmuth'); assert(players[1] === 'Dan Harrington'); }); - + it('should not add players more than once', function() { PlayerInteraction.pollPotentialPlayers(messages, channel, scheduler) .subscribe(function(userId) { players.push(userId); }); - - messages.onNext({ + + messages.onNext({ user: 'Johnny Chan', text: 'Yes, take me back to 87.' }); - - messages.onNext({ + + messages.onNext({ user: 'Johnny Chan', text: 'Hell 88 works too (yes).' }); - + assert(players.length === 1); assert(players[0] === 'Johnny Chan'); }); - + it('should stop polling when the maximum number of players is reached', function() { PlayerInteraction.pollPotentialPlayers(messages, channel, scheduler, 10, 2) .subscribe(function(userId) { players.push(userId); }); - + messages.onNext({user: 'Stu Ungar', text: 'Yes'}); messages.onNext({user: 'Amarillo Slim', text: 'Yes'}); messages.onNext({user: 'Doyle Brunson', text: 'Yes'}); - + assert(players.length === 2); assert(players[0] === 'Stu Ungar'); assert(players[1] === 'Amarillo Slim'); }); - + it('should stop polling when time expires', function() { PlayerInteraction.pollPotentialPlayers(messages, channel, scheduler, 5) .subscribe(function(userId) { players.push(userId); }); - + messages.onNext({user: 'Chris Ferguson', text: 'Yes'}); scheduler.advanceBy(2000); - + messages.onNext({user: 'Scotty Nguyen', text: 'Yes'}); scheduler.advanceBy(4000); - + messages.onNext({user: 'Greg Raymer', text: 'Yes'}); - + assert(players.length === 2); assert(players[0] === 'Chris Ferguson'); assert(players[1] === 'Scotty Nguyen'); }); }); -}); \ No newline at end of file + + describe('the actionFromMessage method', function() { + it('should parse check, call, and fold actions', function() { + var availableActions = ['check', 'bet', 'fold']; + var action = PlayerInteraction.actionFromMessage('C', availableActions); + assert(action.name === 'check'); + + action = PlayerInteraction.actionFromMessage(' CHECK ', availableActions); + assert(action.name === 'check'); + + action = PlayerInteraction.actionFromMessage('Check mumble mumble', availableActions); + assert(action.name == 'check'); + + action = PlayerInteraction.actionFromMessage('mumble mumble Check', availableActions); + assert(action === null); + + action = PlayerInteraction.actionFromMessage('c2', availableActions); + assert(action === null); + + action = PlayerInteraction.actionFromMessage('wat?', availableActions); + assert(action === null); + + action = PlayerInteraction.actionFromMessage('A B C', availableActions); + assert(action === null); + + availableActions = ['call', 'raise', 'fold']; + action = PlayerInteraction.actionFromMessage('c', availableActions); + assert(action.name === 'call'); + + action = PlayerInteraction.actionFromMessage('CaLl', availableActions); + assert(action.name === 'call'); + + action = PlayerInteraction.actionFromMessage('calling a phone number', availableActions); + assert(action === null); + + action = PlayerInteraction.actionFromMessage('FOLD', availableActions); + assert(action.name === 'fold'); + + action = PlayerInteraction.actionFromMessage(' f', availableActions); + assert(action.name === 'fold'); + + action = PlayerInteraction.actionFromMessage(' ffffff', availableActions); + assert(action === null); + }); + + it('should parse bet and raise actions', function() { + var availableActions = ['check', 'bet', 'fold']; + var action = PlayerInteraction.actionFromMessage('BET', availableActions, 5); + assert(action.name === 'bet'); + assert(action.amount === 5); + + action = PlayerInteraction.actionFromMessage('bet 25', availableActions); + assert(action.name === 'bet'); + assert(action.amount === 25); + + action = PlayerInteraction.actionFromMessage('bet 5000', availableActions); + assert(action.name === 'bet'); + assert(action.amount === 5000); + + action = PlayerInteraction.actionFromMessage('bet some money 999', availableActions); + assert(action.name === 'bet'); + assert(action.amount === 1); + + action = PlayerInteraction.actionFromMessage('not a bet', availableActions); + assert(action === null); + + action = PlayerInteraction.actionFromMessage('55 bet 88', availableActions); + assert(action === null); + + availableActions = ['call', 'raise', 'fold']; + action = PlayerInteraction.actionFromMessage('raise infinity', availableActions); + assert(action.name === 'raise'); + assert(action.amount === 1); + + action = PlayerInteraction.actionFromMessage(' RAISE 200 ', availableActions); + assert(action.name === 'raise'); + assert(action.amount === 200); + + action = PlayerInteraction.actionFromMessage('raising children is hard', availableActions); + assert(action === null); + }); + }); +}); From 55b865c1f679c7e458c5c008ed75dc24164708a7 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Thu, 30 Jul 2015 22:58:08 -0700 Subject: [PATCH 02/16] A player action is no longer just a string, but an object with the name and an optional amount. --- src/player-interaction.js | 72 ++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/src/player-interaction.js b/src/player-interaction.js index 913a9b6..7186fb7 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -54,8 +54,8 @@ class PlayerInteraction { // Look for text that conforms to a player action. let playerAction = messages.where((e) => e.user === player.id) - .map((e) => PlayerInteraction.actionForMessage(e.text, availableActions)) - .where((action) => action !== '') + .map((e) => PlayerInteraction.actionFromMessage(e.text, availableActions)) + .where((action) => action !== null) .publish(); playerAction.connect(); @@ -63,7 +63,9 @@ class PlayerInteraction { // If the user times out, they will be auto-folded unless they can check. let actionForTimeout = timeExpired.map(() => - availableActions.indexOf('check') > -1 ? 'check' : 'fold'); + availableActions.indexOf('check') > -1 ? + { name: 'check' } : + { name: 'fold' }); // NB: Take the first result from the player action, the timeout, and a bot // action (only applicable to bots). @@ -73,7 +75,14 @@ class PlayerInteraction { .take(1) .do((action) => { disp.dispose(); - channel.send(`${player.name} ${action}s.`); + let message = `${player.name} ${action.name}s`; + if (action.name === 'bet') + message += ` $${action.amount}.`; + else if (action.name === 'raise') + message += ` to $${action.amount}.`; + else + message += '.'; + channel.send(message); }); } @@ -127,8 +136,8 @@ class PlayerInteraction { // Returns an array of strings static getAvailableActions(player, previousActions) { let actions = _.values(previousActions); - let playerBet = actions.indexOf('bet') > -1; - let playerRaised = actions.indexOf('raise') > -1; + let playerBet = actions.some(a => a.name === 'bet'); + let playerRaised = actions.some(a => a.name === 'raise'); let availableActions = []; @@ -147,34 +156,59 @@ class PlayerInteraction { return availableActions; } - // Private: Maps abbreviated text for a player action to its canonical name. + // Private: Parse player input into a valid action. // - // text - The text of the player message + // text - The text that the player entered // availableActions - An array of the actions available to this player + // defaultBet - (Optional) The default bet amount, used if a number cannot be + // parsed from the input text // - // Returns the canonical action - static actionForMessage(text, availableActions) { - if (!text) return ''; + // Returns an object representing the action, with keys for the name and + // bet amount, or null if the input was invalid. + static actionFromMessage(text, availableActions, defaultBet=1) { + if (!text) return null; - switch (text.toLowerCase()) { + let input = text.trim().toLowerCase().split(/\s+/); + if (!input[0]) return null; + + let name = ''; + let amount = 0; + + switch (input[0]) { case 'c': - return availableActions[0]; + name = availableActions[0]; + break; case 'call': - return 'call'; + name = 'call'; + break; case 'check': - return 'check'; + name = 'check'; + break; case 'f': case 'fold': - return 'fold'; + name = 'fold'; + break; case 'b': case 'bet': - return 'bet'; + name = 'bet'; + amount = PlayerInteraction.betFromMessage(input[1], defaultBet); + break; case 'r': case 'raise': - return 'raise'; + name = 'raise'; + amount = PlayerInteraction.betFromMessage(input[1], defaultBet); + break; default: - return ''; + return null; } + + return { name: name, amount: amount }; + } + + static betFromMessage(text, defaultBet=1) { + if (!text) return defaultBet; + let bet = parseInt(text); + return isNaN(bet) ? defaultBet : bet; } } From 3a498dec1c2fb3752866bf33dfd0e5560f198760 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Thu, 30 Jul 2015 22:58:52 -0700 Subject: [PATCH 03/16] Cascading changes from the new action. --- ai/aggro-bot.js | 4 +++- ai/weak-bot.js | 4 +++- src/player-status.js | 10 +++++++++- src/texas-holdem.js | 15 +++++++++------ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ai/aggro-bot.js b/ai/aggro-bot.js index 78e487e..69131dd 100644 --- a/ai/aggro-bot.js +++ b/ai/aggro-bot.js @@ -13,7 +13,9 @@ class AggroBot { // TOO STRONK getAction(availableActions, previousActions) { - let action = availableActions.indexOf('raise') > -1 ? 'raise' : 'bet'; + let action = availableActions.indexOf('raise') > -1 ? + { name: 'raise' } : + { name: 'bet' }; let delay = 1000 + (Math.random() * 500); return rx.Observable.timer(delay).map(() => action); } diff --git a/ai/weak-bot.js b/ai/weak-bot.js index 7ee66a7..bd1eec2 100644 --- a/ai/weak-bot.js +++ b/ai/weak-bot.js @@ -13,7 +13,9 @@ class WeakBot { // LOL WEAK getAction(availableActions, previousActions) { - let action = availableActions.indexOf('check') > -1 ? 'check' : 'call'; + let action = availableActions.indexOf('check') > -1 ? + { name: 'check' } : + { name: 'call' }; let delay = 1000 + (Math.random() * 3000); return rx.Observable.timer(delay).map(() => action); } diff --git a/src/player-status.js b/src/player-status.js index 8e2d48c..f29310c 100644 --- a/src/player-status.js +++ b/src/player-status.js @@ -37,7 +37,15 @@ class PlayerStatus { let blindIndicator = bigBlindText || smallBlindText || ' '; row.push(blindIndicator); - row.push(player.lastAction || ''); + if (player.lastAction) { + let actionIndicator = player.lastAction.name; + if (actionIndicator === 'bet' || actionIndicator === 'raise') { + actionIndicator += ` $${player.lastAction.amount}`; + } + row.push(actionIndicator); + } else { + row.push(''); + } table.push(row); } diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 2755780..5fa539c 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -147,10 +147,12 @@ class TexasHoldem { // bettor. Because the bet was implict, that player also has an "option," // i.e., they will be the last to act. if (round === 'preflop') { + let smallBlind = this.players[this.smallBlind]; let bigBlind = this.players[this.bigBlind]; bigBlind.isBettor = true; bigBlind.hasOption = true; - previousActions[bigBlind.id] = 'bet'; + previousActions[smallBlind.id] = { name: 'bet', amount: 1 }; + previousActions[bigBlind.id] = { name: 'bet', amount: 2 }; } } @@ -179,7 +181,7 @@ class TexasHoldem { // reference to the acting player. player.lastAction = action; previousActions[player.id] = action; - return {player: player, action: action}; + return { player: player, action: action }; }); }); }); @@ -196,7 +198,7 @@ class TexasHoldem { // // Returns nothing onPlayerAction(player, action, previousActions, roundEnded) { - switch (action) { + switch (action.name) { case 'fold': this.onPlayerFolded(player, roundEnded); break; @@ -251,7 +253,7 @@ class TexasHoldem { onPlayerChecked(player, previousActions, roundEnded) { let playersRemaining = _.filter(this.players, p => p.isInHand); let everyoneChecked = _.every(playersRemaining, p => - p.lastAction === 'check' || p.lastAction === 'call'); + p.lastAction !== null && (p.lastAction.name === 'check' || p.lastAction.name === 'call')); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneChecked && everyoneHadATurn) { @@ -269,7 +271,8 @@ class TexasHoldem { // Returns nothing onPlayerCalled(player, roundEnded) { let playersRemaining = _.filter(this.players, p => p.isInHand && !p.isBettor); - let everyoneCalled = _.every(playersRemaining, p => p.lastAction === 'call'); + let everyoneCalled = _.every(playersRemaining, p => + p.lastAction !== null && p.lastAction.name === 'call'); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneCalled && everyoneHadATurn) { @@ -445,7 +448,7 @@ class TexasHoldem { title: `Dealing the ${round}:`, fallback: this.board.toString(), text: this.board.toString(), - color: "good", + color: 'good', image_url: url }]; From 1e239966284c880b59933c4b24aab0e4b4c8c584 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Thu, 30 Jul 2015 23:48:20 -0700 Subject: [PATCH 04/16] Always treat unavailable actions as invalid, otherwise it's a major loophole. --- src/player-interaction.js | 5 ++++- tests/texas-holdem-spec.js | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/player-interaction.js b/src/player-interaction.js index 7186fb7..eaeebe1 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -202,7 +202,10 @@ class PlayerInteraction { return null; } - return { name: name, amount: amount }; + // NB: Unavailable actions are always invalid. + return availableActions.indexOf(name) > -1 ? + { name: name, amount: amount } : + null; } static betFromMessage(text, defaultBet=1) { diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 69b8f03..9cbf6c6 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -192,19 +192,19 @@ describe('TexasHoldem', function() { // Doyle is SB, Stu is BB, Patrik is UTG. assert(game.actingPlayer.name === 'Patrik Antonius'); - // Check all the way down to Stu. - messages.onNext({user: 4, text: "Check"}); + // Call all the way down to Stu. + messages.onNext({user: 4, text: "Call"}); scheduler.advanceBy(5000); - messages.onNext({user: 5, text: "Check"}); + messages.onNext({user: 5, text: "Call"}); scheduler.advanceBy(5000); - messages.onNext({user: 1, text: "check"}); + messages.onNext({user: 1, text: "call"}); scheduler.advanceBy(5000); - messages.onNext({user: 2, text: "Check"}); + messages.onNext({user: 2, text: "Call"}); scheduler.advanceBy(5000); - // Stu makes a bet. + // Stu has the option, and raises. assert(game.actingPlayer.name === 'Stu Ungar'); - messages.onNext({user: 3, text: "Bet"}); + messages.onNext({user: 3, text: "Raise"}); scheduler.advanceBy(5000); // Everyone folds except Doyle. From 702dca5d1bebf96ea34ac815615c5c2cc161c816 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Thu, 30 Jul 2015 23:58:45 -0700 Subject: [PATCH 05/16] Initialize blinds to 1/2 for now and get more legit using them. --- src/player-interaction.js | 20 ++++++++++----- src/texas-holdem.js | 44 ++++++++++++++++++++++---------- tests/player-interaction-spec.js | 6 ++--- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/player-interaction.js b/src/player-interaction.js index eaeebe1..e7ef4e5 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -40,12 +40,14 @@ class PlayerInteraction { // channel - The {Channel} object, used for posting messages // player - The player being polled // previousActions - A map of players to their most recent action + // defaultBet - The default bet to use // scheduler - (Optional) The scheduler to use for timing events // timeout - (Optional) The amount of time to conduct polling, in seconds // // Returns an {Observable} indicating the action the player took. If time // expires, a 'timeout' action is returned. - static getActionForPlayer(messages, channel, player, previousActions, scheduler=rx.Scheduler.timeout, timeout=30) { + static getActionForPlayer(messages, channel, player, previousActions, defaultBet, + scheduler=rx.Scheduler.timeout, timeout=30) { let intro = `${player.name}, it's your turn to act.`; let availableActions = PlayerInteraction.getAvailableActions(player, previousActions); let formatMessage = (t) => PlayerInteraction.buildActionMessage(availableActions, t); @@ -54,7 +56,7 @@ class PlayerInteraction { // Look for text that conforms to a player action. let playerAction = messages.where((e) => e.user === player.id) - .map((e) => PlayerInteraction.actionFromMessage(e.text, availableActions)) + .map((e) => PlayerInteraction.actionFromMessage(e.text, availableActions, defaultBet)) .where((action) => action !== null) .publish(); @@ -160,12 +162,12 @@ class PlayerInteraction { // // text - The text that the player entered // availableActions - An array of the actions available to this player - // defaultBet - (Optional) The default bet amount, used if a number cannot be - // parsed from the input text + // defaultBet - The default bet amount, used if a number cannot be parsed + // from the input text // // Returns an object representing the action, with keys for the name and // bet amount, or null if the input was invalid. - static actionFromMessage(text, availableActions, defaultBet=1) { + static actionFromMessage(text, availableActions, defaultBet) { if (!text) return null; let input = text.trim().toLowerCase().split(/\s+/); @@ -208,7 +210,13 @@ class PlayerInteraction { null; } - static betFromMessage(text, defaultBet=1) { + // Private: Parse the bet amount from a string. + // + // text - The player input + // defaultBet - The default bet to use if the parse fails + // + // Returns a number representing the bet amount + static betFromMessage(text, defaultBet) { if (!text) return defaultBet; let bet = parseInt(text); return isNaN(bet) ? defaultBet : bet; diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 5fa539c..3f5ff3f 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -31,6 +31,8 @@ class TexasHoldem { } this.deck = new Deck(); + this.smallBlind = 1; + this.bigBlind = this.smallBlind * 2; this.quitGame = new rx.Subject(); } @@ -143,17 +145,30 @@ class TexasHoldem { player.hasOption = false; } + if (round === 'preflop') { + this.postBlinds(previousActions); + } + } + + // Private: Posts blinds for a betting round. + // + // previousActions - A map of players to their most recent action + // + // Returns nothing + postBlinds(previousActions) { + let sbPlayer = this.players[this.smallBlindIdx]; + let bbPlayer = this.players[this.bigBlindIdx]; + // NB: So, in the preflop round we want to treat the big blind as the // bettor. Because the bet was implict, that player also has an "option," // i.e., they will be the last to act. - if (round === 'preflop') { - let smallBlind = this.players[this.smallBlind]; - let bigBlind = this.players[this.bigBlind]; - bigBlind.isBettor = true; - bigBlind.hasOption = true; - previousActions[smallBlind.id] = { name: 'bet', amount: 1 }; - previousActions[bigBlind.id] = { name: 'bet', amount: 2 }; - } + bbPlayer.isBettor = true; + bbPlayer.hasOption = true; + + previousActions[sbPlayer.id] = + sbPlayer.lastAction = { name: 'bet', amount: this.smallBlind }; + previousActions[bbPlayer.id] = + bbPlayer.lastAction = { name: 'bet', amount: this.bigBlind }; } // Private: Displays player position and who's next to act, pauses briefly, @@ -170,12 +185,13 @@ class TexasHoldem { // Display player position and who's next to act before polling. PlayerStatus.displayHandStatus(this.channel, this.players, player, - this.dealerButton, this.bigBlind, this.smallBlind, this.tableFormatter); + this.dealerButton, this.bigBlindIdx, this.smallBlindIdx, this.tableFormatter); return rx.Observable.timer(timeToPause, this.scheduler).flatMap(() => { this.actingPlayer = player; - return PlayerInteraction.getActionForPlayer(this.messages, this.channel, player, previousActions, this.scheduler) + return PlayerInteraction.getActionForPlayer(this.messages, this.channel, + player, previousActions, this.smallBlind, this.scheduler) .map((action) => { // NB: Save the action in various structures and return it with a // reference to the acting player. @@ -391,8 +407,8 @@ class TexasHoldem { handEnded.onCompleted(); } - // Private: Adds players to the hand if they have enough chips and posts - // blinds. + // Private: Adds players to the hand if they have enough chips and determines + // small blind and big blind indices. // // Returns nothing setupPlayers() { @@ -401,8 +417,8 @@ class TexasHoldem { player.isBettor = false; } - this.smallBlind = (this.dealerButton + 1) % this.players.length; - this.bigBlind = (this.smallBlind + 1) % this.players.length; + this.smallBlindIdx = (this.dealerButton + 1) % this.players.length; + this.bigBlindIdx = (this.smallBlindIdx + 1) % this.players.length; } // Private: Deals hole cards to each player in the game. To communicate this diff --git a/tests/player-interaction-spec.js b/tests/player-interaction-spec.js index ce62529..88943ca 100644 --- a/tests/player-interaction-spec.js +++ b/tests/player-interaction-spec.js @@ -155,9 +155,9 @@ describe('PlayerInteraction', function() { assert(action.name === 'bet'); assert(action.amount === 5000); - action = PlayerInteraction.actionFromMessage('bet some money 999', availableActions); + action = PlayerInteraction.actionFromMessage('bet some money 999', availableActions, 2); assert(action.name === 'bet'); - assert(action.amount === 1); + assert(action.amount === 2); action = PlayerInteraction.actionFromMessage('not a bet', availableActions); assert(action === null); @@ -166,7 +166,7 @@ describe('PlayerInteraction', function() { assert(action === null); availableActions = ['call', 'raise', 'fold']; - action = PlayerInteraction.actionFromMessage('raise infinity', availableActions); + action = PlayerInteraction.actionFromMessage('raise infinity', availableActions, 1); assert(action.name === 'raise'); assert(action.amount === 1); From e1cbb947c3fa7c5c3d5daf194f67b9b6e8881514 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Fri, 31 Jul 2015 00:32:46 -0700 Subject: [PATCH 06/16] Set initial player chips to 100 BB's. --- src/player-status.js | 1 + src/texas-holdem.js | 27 ++++++++++++++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/player-status.js b/src/player-status.js index f29310c..2e13d97 100644 --- a/src/player-status.js +++ b/src/player-status.js @@ -25,6 +25,7 @@ class PlayerStatus { let player = players[idx]; let turnIndicator = player === actingPlayer ? '→ ' : ' '; row.push(`${turnIndicator}${player.name}`); + row.push(`$${player.chips}`); let handIndicator = player.isInHand ? '🂠' : ' '; row.push(handIndicator); diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 3f5ff3f..6455200 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -23,17 +23,20 @@ class TexasHoldem { this.players = players; this.scheduler = scheduler; + this.deck = new Deck(); + this.smallBlind = 1; + this.bigBlind = this.smallBlind * 2; + this.quitGame = new rx.Subject(); + // Cache the direct message channels for each player as we'll be using // them often, and fetching them takes linear time per number of users. this.playerDms = {}; for (let player of this.players) { this.playerDms[player.id] = this.slack.getDMByName(player.name); - } - this.deck = new Deck(); - this.smallBlind = 1; - this.bigBlind = this.smallBlind * 2; - this.quitGame = new rx.Subject(); + // Each player starts with 100 big blinds. + player.chips = this.bigBlind * 100; + } } // Public: Starts a new game. @@ -267,9 +270,7 @@ class TexasHoldem { // // Returns nothing onPlayerChecked(player, previousActions, roundEnded) { - let playersRemaining = _.filter(this.players, p => p.isInHand); - let everyoneChecked = _.every(playersRemaining, p => - p.lastAction !== null && (p.lastAction.name === 'check' || p.lastAction.name === 'call')); + let everyoneChecked = this.everyPlayerTookAction(['check', 'call'], p => p.isInHand); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneChecked && everyoneHadATurn) { @@ -286,9 +287,7 @@ class TexasHoldem { // // Returns nothing onPlayerCalled(player, roundEnded) { - let playersRemaining = _.filter(this.players, p => p.isInHand && !p.isBettor); - let everyoneCalled = _.every(playersRemaining, p => - p.lastAction !== null && p.lastAction.name === 'call'); + let everyoneCalled = this.everyPlayerTookAction(['call'], p => p.isInHand && !p.isBettor); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneCalled && everyoneHadATurn) { @@ -297,6 +296,12 @@ class TexasHoldem { } } + everyPlayerTookAction(actions, playerPredicate) { + let playersRemaining = _.filter(this.players, playerPredicate); + return _.every(playersRemaining, p => p.lastAction !== null && + actions.indexOf(p.lastAction.name) > -1); + } + // Private: When a player bets, assign them as the current bettor. The // betting round will cycle through all players up to the bettor. // From 62130913a163d43f7ea8a76385b1a5758f3a8aa5 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sat, 1 Aug 2015 16:33:56 -0700 Subject: [PATCH 07/16] Getting started with pot tracking, and subtracting player chips. --- src/player-status.js | 17 ++++++++-------- src/texas-holdem.js | 48 +++++++++++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/player-status.js b/src/player-status.js index 2e13d97..bc27af4 100644 --- a/src/player-status.js +++ b/src/player-status.js @@ -8,6 +8,7 @@ class PlayerStatus { // channel - The channel where the status message will be displayed // players - The players in the hand // actingPlayer - The player taking action + // currentPot - The total amount of chips in the pot // dealerButton - The index of the dealer button // bigBlind - The index of the big blind // smallBlind - The index of the small blind @@ -16,7 +17,7 @@ class PlayerStatus { // // Returns nothing static displayHandStatus(channel, players, actingPlayer, - dealerButton, bigBlind, smallBlind, tableFormatter=`\`\`\``) { + currentPot, dealerButton, bigBlind, smallBlind, tableFormatter=`\`\`\``) { let table = []; for (let idx = 0; idx < players.length; idx++) { @@ -30,13 +31,11 @@ class PlayerStatus { let handIndicator = player.isInHand ? '🂠' : ' '; row.push(handIndicator); - let dealerIndicator = idx === dealerButton ? 'Ⓓ' : ' '; - row.push(dealerIndicator); - - let bigBlindText = idx === bigBlind ? 'Ⓑ' : null; + let dealerText = idx === dealerButton ? 'Ⓓ' : null; let smallBlindText = idx === smallBlind ? 'Ⓢ' : null; - let blindIndicator = bigBlindText || smallBlindText || ' '; - row.push(blindIndicator); + let bigBlindText = idx === bigBlind ? 'Ⓑ' : null; + let positionIndicator = bigBlindText || smallBlindText || dealerText || ' '; + row.push(positionIndicator); if (player.lastAction) { let actionIndicator = player.lastAction.name; @@ -52,7 +51,9 @@ class PlayerStatus { } let fixedWidthTable = `${tableFormatter}${textTable(table)}${tableFormatter}`; - channel.send(fixedWidthTable); + let handStatus = `${fixedWidthTable}\nPot: $${currentPot}`; + + channel.send(handStatus); } } diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 6455200..a39830d 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -165,7 +165,8 @@ class TexasHoldem { // NB: So, in the preflop round we want to treat the big blind as the // bettor. Because the bet was implict, that player also has an "option," // i.e., they will be the last to act. - bbPlayer.isBettor = true; + this.onPlayerBet(sbPlayer, this.smallBlind); + this.onPlayerBet(bbPlayer, this.bigBlind); bbPlayer.hasOption = true; previousActions[sbPlayer.id] = @@ -187,8 +188,11 @@ class TexasHoldem { return rx.Observable.defer(() => { // Display player position and who's next to act before polling. - PlayerStatus.displayHandStatus(this.channel, this.players, player, - this.dealerButton, this.bigBlindIdx, this.smallBlindIdx, this.tableFormatter); + PlayerStatus.displayHandStatus(this.channel, + this.players, player, + this.currentPot, this.dealerButton, + this.bigBlindIdx, this.smallBlindIdx, + this.tableFormatter); return rx.Observable.timer(timeToPause, this.scheduler).flatMap(() => { this.actingPlayer = player; @@ -229,7 +233,7 @@ class TexasHoldem { break; case 'bet': case 'raise': - this.onPlayerBet(player); + this.onPlayerBet(player, action.amount); break; } } @@ -287,6 +291,8 @@ class TexasHoldem { // // Returns nothing onPlayerCalled(player, roundEnded) { + this.updatePlayerChips(player, this.currentBet); + let everyoneCalled = this.everyPlayerTookAction(['call'], p => p.isInHand && !p.isBettor); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); @@ -296,25 +302,41 @@ class TexasHoldem { } } - everyPlayerTookAction(actions, playerPredicate) { - let playersRemaining = _.filter(this.players, playerPredicate); - return _.every(playersRemaining, p => p.lastAction !== null && - actions.indexOf(p.lastAction.name) > -1); - } - // Private: When a player bets, assign them as the current bettor. The // betting round will cycle through all players up to the bettor. // // player - The player who bet or raised + // amount - The amount that was bet // // Returns nothing - onPlayerBet(player) { + onPlayerBet(player, amount) { let currentBettor = _.find(this.players, p => p.isBettor); if (currentBettor) { currentBettor.isBettor = false; currentBettor.hasOption = false; } + player.isBettor = true; + this.currentBet = amount; + this.updatePlayerChips(player, amount); + } + + // Private: Update a player's chip stack and the pot based on a wager. + // + // player - The calling / betting player + // amount - The amount wagered + // + // Returns nothing + updatePlayerChips(player, amount) { + // TODO: Handle player running out of chips / split pots. + player.chips -= amount; + this.currentPot += amount; + } + + everyPlayerTookAction(actions, playerPredicate) { + let playersRemaining = _.filter(this.players, playerPredicate); + return _.every(playersRemaining, p => p.lastAction !== null && + actions.indexOf(p.lastAction.name) > -1); } // Private: Displays the flop cards and does a round of betting. If the @@ -396,12 +418,13 @@ class TexasHoldem { }); message += ` with ${result.handName}: ${result.hand.toString()}.`; } else { - message = `${result.winners[0].name} wins`; + message = `${result.winners[0].name} wins $${this.currentPot}`; if (result.hand) { message += ` with ${result.handName}: ${result.hand.toString()}.`; } else { message += '.'; } + result.winners[0].chips += this.currentPot; } this.channel.send(message); @@ -422,6 +445,7 @@ class TexasHoldem { player.isBettor = false; } + this.currentPot = 0; this.smallBlindIdx = (this.dealerButton + 1) % this.players.length; this.bigBlindIdx = (this.smallBlindIdx + 1) % this.players.length; } From 901ac4ffbca6f4ca0039509fd9612083475d9671 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sat, 1 Aug 2015 17:31:46 -0700 Subject: [PATCH 08/16] Write a (failing) test for all-in logic. --- tests/texas-holdem-spec.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 9cbf6c6..a4b7b9c 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -46,6 +46,36 @@ describe('TexasHoldem', function() { game.tableFormatter = "\n"; }); + it.only('should handle all-ins correctly', function() { + game.start(0); + scheduler.advanceBy(5000); + + messages.onNext({user: 4, text: "call"}); + scheduler.advanceBy(5000); + messages.onNext({user: 5, text: "fold"}); + scheduler.advanceBy(5000); + messages.onNext({user: 1, text: "raise 20"}); + scheduler.advanceBy(5000); + messages.onNext({user: 2, text: "Fold"}); + scheduler.advanceBy(5000); + messages.onNext({user: 3, text: "Fold"}); + scheduler.advanceBy(5000); + + messages.onNext({user: 4, text: "Raise 198"}); + scheduler.advanceBy(5000); + messages.onNext({user: 1, text: "Call"}); + scheduler.advanceBy(5000); + + assert(players[0].chips === 0); + assert(players[3].chips === 0); + assert(game.currentPot === 403); + + var winner = game.lastHandResult.winners[0]; + assert(winner.id === 1 || winner.id === 4); + assert(game.board.length === 0); + game.quit(); + }); + it('should handle split pots correctly', function() { game.start(0); scheduler.advanceBy(5000); From 2382a5f642b6b197f477daf3c2084c3bf835be8d Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sat, 1 Aug 2015 18:14:24 -0700 Subject: [PATCH 09/16] Prevent players from betting more chips than they have. --- src/player-interaction.js | 48 +++++++++++++++++++++----------- src/texas-holdem.js | 11 ++++++-- tests/player-interaction-spec.js | 23 +++++++++++++++ tests/texas-holdem-spec.js | 4 ++- 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/player-interaction.js b/src/player-interaction.js index e7ef4e5..0bed4e2 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -56,7 +56,8 @@ class PlayerInteraction { // Look for text that conforms to a player action. let playerAction = messages.where((e) => e.user === player.id) - .map((e) => PlayerInteraction.actionFromMessage(e.text, availableActions, defaultBet)) + .map((e) => PlayerInteraction.actionFromMessage(e.text, + availableActions, defaultBet, player.chips)) .where((action) => action !== null) .publish(); @@ -69,25 +70,32 @@ class PlayerInteraction { { name: 'check' } : { name: 'fold' }); + let botAction = player.isBot ? + player.getAction(availableActions, previousActions) : + rx.Observable.never(); + // NB: Take the first result from the player action, the timeout, and a bot // action (only applicable to bots). - return rx.Observable - .merge(playerAction, actionForTimeout, - player.isBot ? player.getAction(availableActions, previousActions) : rx.Observable.never()) + return rx.Observable.merge(playerAction, actionForTimeout, botAction) .take(1) .do((action) => { disp.dispose(); - let message = `${player.name} ${action.name}s`; - if (action.name === 'bet') - message += ` $${action.amount}.`; - else if (action.name === 'raise') - message += ` to $${action.amount}.`; - else - message += '.'; - channel.send(message); + PlayerInteraction.afterPlayerAction(channel, player, action); }); } + static afterPlayerAction(channel, player, action) { + let message = `${player.name} ${action.name}s`; + if (action.name === 'bet') + message += ` $${action.amount}.`; + else if (action.name === 'raise') + message += ` to $${action.amount}.`; + else + message += '.'; + + channel.send(message); + } + // Private: Posts a message to the channel with some timeout, that edits // itself each second to provide a countdown. // @@ -164,10 +172,11 @@ class PlayerInteraction { // availableActions - An array of the actions available to this player // defaultBet - The default bet amount, used if a number cannot be parsed // from the input text + // playerChips - The amount of chips the player has remaining // // Returns an object representing the action, with keys for the name and // bet amount, or null if the input was invalid. - static actionFromMessage(text, availableActions, defaultBet) { + static actionFromMessage(text, availableActions, defaultBet, playerChips) { if (!text) return null; let input = text.trim().toLowerCase().split(/\s+/); @@ -193,12 +202,12 @@ class PlayerInteraction { case 'b': case 'bet': name = 'bet'; - amount = PlayerInteraction.betFromMessage(input[1], defaultBet); + amount = PlayerInteraction.betFromMessage(input[1], defaultBet, playerChips); break; case 'r': case 'raise': name = 'raise'; - amount = PlayerInteraction.betFromMessage(input[1], defaultBet); + amount = PlayerInteraction.betFromMessage(input[1], defaultBet, playerChips); break; default: return null; @@ -214,12 +223,17 @@ class PlayerInteraction { // // text - The player input // defaultBet - The default bet to use if the parse fails + // playerChips - The amount of chips the player has remaining // // Returns a number representing the bet amount - static betFromMessage(text, defaultBet) { + static betFromMessage(text, defaultBet, playerChips) { if (!text) return defaultBet; + let bet = parseInt(text); - return isNaN(bet) ? defaultBet : bet; + bet = isNaN(bet) ? defaultBet : bet; + bet = (playerChips && playerChips < bet) ? playerChips : bet; + + return bet; } } diff --git a/src/texas-holdem.js b/src/texas-holdem.js index a39830d..6df6c4c 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -328,9 +328,14 @@ class TexasHoldem { // // Returns nothing updatePlayerChips(player, amount) { - // TODO: Handle player running out of chips / split pots. - player.chips -= amount; - this.currentPot += amount; + if (player.chips <= amount) { + player.isAllIn = true; + this.currentPot += player.chips; + player.chips = 0; + } else { + player.chips -= amount; + this.currentPot += amount; + } } everyPlayerTookAction(actions, playerPredicate) { diff --git a/tests/player-interaction-spec.js b/tests/player-interaction-spec.js index 88943ca..36bf738 100644 --- a/tests/player-interaction-spec.js +++ b/tests/player-interaction-spec.js @@ -177,5 +177,28 @@ describe('PlayerInteraction', function() { action = PlayerInteraction.actionFromMessage('raising children is hard', availableActions); assert(action === null); }); + + it('should not let players bet more chips than they have', function() { + var availableActions = ['bet', 'raise', 'fold']; + var action = PlayerInteraction.actionFromMessage('bet 25', availableActions, 5, 10); + assert(action.name === 'bet'); + assert(action.amount === 10); + + action = PlayerInteraction.actionFromMessage('bet 25', availableActions, 5, 2); + assert(action.name === 'bet'); + assert(action.amount === 2); + + action = PlayerInteraction.actionFromMessage('bet infinity', availableActions, 5, 2); + assert(action.name === 'bet'); + assert(action.amount === 2); + + action = PlayerInteraction.actionFromMessage('raise 100', availableActions, 1, 99); + assert(action.name === 'raise'); + assert(action.amount === 99); + + action = PlayerInteraction.actionFromMessage('raise ABC', availableActions, 2, 10); + assert(action.name === 'raise'); + assert(action.amount === 2); + }); }); }); diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index a4b7b9c..0f445c8 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -61,8 +61,10 @@ describe('TexasHoldem', function() { messages.onNext({user: 3, text: "Fold"}); scheduler.advanceBy(5000); - messages.onNext({user: 4, text: "Raise 198"}); + messages.onNext({user: 4, text: "Raise 200"}); scheduler.advanceBy(5000); + assert(game.currentBet === 198); + messages.onNext({user: 1, text: "Call"}); scheduler.advanceBy(5000); From bfaad487bd29696ad1128959388373d44bb29cc8 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sat, 1 Aug 2015 18:40:25 -0700 Subject: [PATCH 10/16] Abort betting rounds when all players are all-in. --- src/texas-holdem.js | 9 ++++++++- tests/texas-holdem-spec.js | 7 +++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 6df6c4c..a6c13d6 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -109,6 +109,13 @@ class TexasHoldem { // // Returns an {Observable} signaling the completion of the round doBettingRound(round) { + // NB: If every player is already all-in, end this round early. + let playersRemaining = _.filter(this.players, p => p.isInHand); + if (_.every(playersRemaining, p => p.isAllIn)) { + let result = { isHandComplete: false }; + return rx.Observable.return(result); + } + this.orderedPlayers = PlayerOrder.determine(this.players, this.dealerButton, round); let previousActions = {}; let roundEnded = new rx.Subject(); @@ -119,7 +126,7 @@ class TexasHoldem { // an action. This cycle will be repeated until the round is ended, which // can occur after any player action. let queryPlayers = rx.Observable.fromArray(this.orderedPlayers) - .where((player) => player.isInHand) + .where((player) => player.isInHand && !player.isAllIn) .concatMap((player) => this.deferredActionForPlayer(player, previousActions)) .repeat() .reduce((acc, x) => { diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 0f445c8..29a72ed 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -64,13 +64,12 @@ describe('TexasHoldem', function() { messages.onNext({user: 4, text: "Raise 200"}); scheduler.advanceBy(5000); assert(game.currentBet === 198); + assert(players[3].chips === 0); + assert(players[3].isAllIn); messages.onNext({user: 1, text: "Call"}); - scheduler.advanceBy(5000); - - assert(players[0].chips === 0); - assert(players[3].chips === 0); assert(game.currentPot === 403); + scheduler.advanceBy(5000); var winner = game.lastHandResult.winners[0]; assert(winner.id === 1 || winner.id === 4); From bbc994f367a7776042132305649ec6ab776ed075 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 12:17:26 -0700 Subject: [PATCH 11/16] The player interaction class is taking on too many responsibilities. It shouldn't be responsible for picking a default bet; that should be up to the game. Adjust specs accordingly. --- src/player-interaction.js | 61 +++++++------------------------- src/texas-holdem.js | 37 +++++++++++++++++-- tests/player-interaction-spec.js | 35 ++++-------------- tests/texas-holdem-spec.js | 3 +- 4 files changed, 55 insertions(+), 81 deletions(-) diff --git a/src/player-interaction.js b/src/player-interaction.js index 0bed4e2..28509cd 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -15,14 +15,14 @@ class PlayerInteraction { // `onCompleted` when time expires or the max number of players join. static pollPotentialPlayers(messages, channel, scheduler=rx.Scheduler.timeout, timeout=5, maxPlayers=6) { let intro = `Who wants to play?`; - let formatMessage = (t) => `Respond with 'yes' in this channel in the next ${t} seconds.`; + let formatMessage = t => `Respond with 'yes' in this channel in the next ${t} seconds.`; let {timeExpired} = PlayerInteraction.postMessageWithTimeout(channel, intro, formatMessage, scheduler, timeout); // Look for messages containing the word 'yes' and map them to a unique // user ID, constrained to `maxPlayers` number of players. - let newPlayers = messages.where((e) => e.text && e.text.toLowerCase().match(/\byes\b/)) - .map((e) => e.user) + let newPlayers = messages.where(e => e.text && e.text.toLowerCase().match(/\byes\b/)) + .map(e => e.user) .distinct() .take(maxPlayers) .publish(); @@ -40,25 +40,23 @@ class PlayerInteraction { // channel - The {Channel} object, used for posting messages // player - The player being polled // previousActions - A map of players to their most recent action - // defaultBet - The default bet to use // scheduler - (Optional) The scheduler to use for timing events // timeout - (Optional) The amount of time to conduct polling, in seconds // // Returns an {Observable} indicating the action the player took. If time // expires, a 'timeout' action is returned. - static getActionForPlayer(messages, channel, player, previousActions, defaultBet, + static getActionForPlayer(messages, channel, player, previousActions, scheduler=rx.Scheduler.timeout, timeout=30) { let intro = `${player.name}, it's your turn to act.`; let availableActions = PlayerInteraction.getAvailableActions(player, previousActions); - let formatMessage = (t) => PlayerInteraction.buildActionMessage(availableActions, t); + let formatMessage = t => PlayerInteraction.buildActionMessage(availableActions, t); let {timeExpired} = PlayerInteraction.postMessageWithTimeout(channel, intro, formatMessage, scheduler, timeout); // Look for text that conforms to a player action. - let playerAction = messages.where((e) => e.user === player.id) - .map((e) => PlayerInteraction.actionFromMessage(e.text, - availableActions, defaultBet, player.chips)) - .where((action) => action !== null) + let playerAction = messages.where(e => e.user === player.id) + .map(e => PlayerInteraction.actionFromMessage(e.text, availableActions)) + .where(action => action !== null) .publish(); playerAction.connect(); @@ -78,22 +76,7 @@ class PlayerInteraction { // action (only applicable to bots). return rx.Observable.merge(playerAction, actionForTimeout, botAction) .take(1) - .do((action) => { - disp.dispose(); - PlayerInteraction.afterPlayerAction(channel, player, action); - }); - } - - static afterPlayerAction(channel, player, action) { - let message = `${player.name} ${action.name}s`; - if (action.name === 'bet') - message += ` $${action.amount}.`; - else if (action.name === 'raise') - message += ` to $${action.amount}.`; - else - message += '.'; - - channel.send(message); + .do(() => disp.dispose()); } // Private: Posts a message to the channel with some timeout, that edits @@ -170,13 +153,10 @@ class PlayerInteraction { // // text - The text that the player entered // availableActions - An array of the actions available to this player - // defaultBet - The default bet amount, used if a number cannot be parsed - // from the input text - // playerChips - The amount of chips the player has remaining // // Returns an object representing the action, with keys for the name and // bet amount, or null if the input was invalid. - static actionFromMessage(text, availableActions, defaultBet, playerChips) { + static actionFromMessage(text, availableActions) { if (!text) return null; let input = text.trim().toLowerCase().split(/\s+/); @@ -202,12 +182,12 @@ class PlayerInteraction { case 'b': case 'bet': name = 'bet'; - amount = PlayerInteraction.betFromMessage(input[1], defaultBet, playerChips); + amount = input[1] ? parseInt(input[1]) : NaN; break; case 'r': case 'raise': name = 'raise'; - amount = PlayerInteraction.betFromMessage(input[1], defaultBet, playerChips); + amount = input[1] ? parseInt(input[1]) : NaN; break; default: return null; @@ -218,23 +198,6 @@ class PlayerInteraction { { name: name, amount: amount } : null; } - - // Private: Parse the bet amount from a string. - // - // text - The player input - // defaultBet - The default bet to use if the parse fails - // playerChips - The amount of chips the player has remaining - // - // Returns a number representing the bet amount - static betFromMessage(text, defaultBet, playerChips) { - if (!text) return defaultBet; - - let bet = parseInt(text); - bet = isNaN(bet) ? defaultBet : bet; - bet = (playerChips && playerChips < bet) ? playerChips : bet; - - return bet; - } } module.exports = PlayerInteraction; diff --git a/src/texas-holdem.js b/src/texas-holdem.js index a6c13d6..5a447f5 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -155,6 +155,8 @@ class TexasHoldem { player.hasOption = false; } + this.currentBet = null; + if (round === 'preflop') { this.postBlinds(previousActions); } @@ -205,8 +207,11 @@ class TexasHoldem { this.actingPlayer = player; return PlayerInteraction.getActionForPlayer(this.messages, this.channel, - player, previousActions, this.smallBlind, this.scheduler) - .map((action) => { + player, previousActions, this.scheduler) + .map(action => { + this.validatePlayerAction(player, action); + this.postActionToChannel(player, action); + // NB: Save the action in various structures and return it with a // reference to the acting player. player.lastAction = action; @@ -217,6 +222,34 @@ class TexasHoldem { }); } + validatePlayerAction(player, action) { + if (action.name === 'bet' || action.name === 'raise') { + // If another player has bet, the default raise is 2x. Otherwise the + // minimum bet is 1 small blind. + if (isNaN(action.amount)) { + action.amount = this.currentBet ? + this.currentBet * 2 : + this.smallBlind; + } + + if (action.amount >= player.chips) { + action.amount = player.chips; + } + } + } + + postActionToChannel(player, action) { + let message = `${player.name} ${action.name}s`; + if (action.name === 'bet') + message += ` $${action.amount}.`; + else if (action.name === 'raise') + message += ` to $${action.amount}.`; + else + message += '.'; + + this.channel.send(message); + } + // Private: Occurs when a player action is received. Check the remaining // players and the previous actions, and possibly end the round of betting or // the hand entirely. diff --git a/tests/player-interaction-spec.js b/tests/player-interaction-spec.js index 36bf738..eff673c 100644 --- a/tests/player-interaction-spec.js +++ b/tests/player-interaction-spec.js @@ -143,9 +143,9 @@ describe('PlayerInteraction', function() { it('should parse bet and raise actions', function() { var availableActions = ['check', 'bet', 'fold']; - var action = PlayerInteraction.actionFromMessage('BET', availableActions, 5); + var action = PlayerInteraction.actionFromMessage('BET', availableActions); assert(action.name === 'bet'); - assert(action.amount === 5); + assert(isNaN(action.amount)); action = PlayerInteraction.actionFromMessage('bet 25', availableActions); assert(action.name === 'bet'); @@ -155,9 +155,9 @@ describe('PlayerInteraction', function() { assert(action.name === 'bet'); assert(action.amount === 5000); - action = PlayerInteraction.actionFromMessage('bet some money 999', availableActions, 2); + action = PlayerInteraction.actionFromMessage('bet some money 999', availableActions); assert(action.name === 'bet'); - assert(action.amount === 2); + assert(isNaN(action.amount)); action = PlayerInteraction.actionFromMessage('not a bet', availableActions); assert(action === null); @@ -166,9 +166,9 @@ describe('PlayerInteraction', function() { assert(action === null); availableActions = ['call', 'raise', 'fold']; - action = PlayerInteraction.actionFromMessage('raise infinity', availableActions, 1); + action = PlayerInteraction.actionFromMessage('raise infinity', availableActions); assert(action.name === 'raise'); - assert(action.amount === 1); + assert(isNaN(action.amount)); action = PlayerInteraction.actionFromMessage(' RAISE 200 ', availableActions); assert(action.name === 'raise'); @@ -177,28 +177,5 @@ describe('PlayerInteraction', function() { action = PlayerInteraction.actionFromMessage('raising children is hard', availableActions); assert(action === null); }); - - it('should not let players bet more chips than they have', function() { - var availableActions = ['bet', 'raise', 'fold']; - var action = PlayerInteraction.actionFromMessage('bet 25', availableActions, 5, 10); - assert(action.name === 'bet'); - assert(action.amount === 10); - - action = PlayerInteraction.actionFromMessage('bet 25', availableActions, 5, 2); - assert(action.name === 'bet'); - assert(action.amount === 2); - - action = PlayerInteraction.actionFromMessage('bet infinity', availableActions, 5, 2); - assert(action.name === 'bet'); - assert(action.amount === 2); - - action = PlayerInteraction.actionFromMessage('raise 100', availableActions, 1, 99); - assert(action.name === 'raise'); - assert(action.amount === 99); - - action = PlayerInteraction.actionFromMessage('raise ABC', availableActions, 2, 10); - assert(action.name === 'raise'); - assert(action.amount === 2); - }); }); }); diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 29a72ed..5c2b2f6 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -46,7 +46,7 @@ describe('TexasHoldem', function() { game.tableFormatter = "\n"; }); - it.only('should handle all-ins correctly', function() { + it('should handle all-ins correctly', function() { game.start(0); scheduler.advanceBy(5000); @@ -63,6 +63,7 @@ describe('TexasHoldem', function() { messages.onNext({user: 4, text: "Raise 200"}); scheduler.advanceBy(5000); + assert(game.currentBet === 198); assert(players[3].chips === 0); assert(players[3].isAllIn); From 23a4b7f46ae4b10edd7feea32d477f34fe914648 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 12:30:36 -0700 Subject: [PATCH 12/16] Write a test that verifies default bets and raises. --- src/texas-holdem.js | 2 +- tests/texas-holdem-spec.js | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 5a447f5..ec99a96 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -155,7 +155,7 @@ class TexasHoldem { player.hasOption = false; } - this.currentBet = null; + this.currentBet = 0; if (round === 'preflop') { this.postBlinds(previousActions); diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 5c2b2f6..db15947 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -46,6 +46,48 @@ describe('TexasHoldem', function() { game.tableFormatter = "\n"; }); + it('should handle default bets and raises', function() { + game.start(0); + scheduler.advanceBy(5000); + + messages.onNext({user: 4, text: "raise"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 4); + assert(game.currentPot === 7); + + messages.onNext({user: 5, text: "raise"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 8); + assert(game.currentPot === 15); + + messages.onNext({user: 1, text: "raise"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 16); + assert(game.currentPot === 31); + + messages.onNext({user: 2, text: "fold"}); + scheduler.advanceBy(5000); + messages.onNext({user: 3, text: "fold"}); + scheduler.advanceBy(5000); + + messages.onNext({user: 4, text: "call"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 16); + assert(game.currentPot === 47); + + messages.onNext({user: 5, text: "call"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 0); + assert(game.currentPot === 63); + + messages.onNext({user: 4, text: "bet"}); + scheduler.advanceBy(5000); + assert(game.currentBet === 1); + assert(game.currentPot === 64); + + game.quit(); + }); + it('should handle all-ins correctly', function() { game.start(0); scheduler.advanceBy(5000); From ad9180b5be9317acbbf10d9671f569ec6bbe456f Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 13:46:35 -0700 Subject: [PATCH 13/16] Handle player eliminations correctly. --- src/player-order.js | 18 ++++++++++++++- src/player-status.js | 4 ++-- src/texas-holdem.js | 45 ++++++++++++++++++++------------------ tests/texas-holdem-spec.js | 3 +++ 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/player-order.js b/src/player-order.js index 1e17aaa..0e6c41d 100644 --- a/src/player-order.js +++ b/src/player-order.js @@ -34,7 +34,7 @@ module.exports = class PlayerOrder { // comparing indices. // // actingPlayer - The player who is acting - // players - A list of all players in the hand, sorted by position + // players - An array of all players in the hand, sorted by position // // Returns true if this player is the last to act, false otherwise static isLastToAct(actingPlayer, players) { @@ -59,4 +59,20 @@ module.exports = class PlayerOrder { actingPlayer === playerWithOption : (currentIndex + 1) % playersRemaining.length === bettorIndex; } + + // Public: Returns the index of the next player to act. + // + // index - The current index + // players - An array of all players in the hand, sorted by position + // + // Returns the index of the next player in the hand + static getNextPlayerIndex(index, players) { + let player = null; + do { + index = (index + 1) % players.length; + player = players[index]; + } while (!player.isInHand); + + return index; + } }; diff --git a/src/player-status.js b/src/player-status.js index bc27af4..3089688 100644 --- a/src/player-status.js +++ b/src/player-status.js @@ -2,11 +2,11 @@ const textTable = require('text-table'); class PlayerStatus { // Public: Displays a fixed-width text table showing all of the players in - // the hand, relevant position information (blinds, dealer button), + // the game, relevant position information (blinds, dealer button), // information about the player's bet, and an indicator of who's next to act. // // channel - The channel where the status message will be displayed - // players - The players in the hand + // players - The players in the game // actingPlayer - The player taking action // currentPot - The total amount of chips in the pot // dealerButton - The index of the dealer button diff --git a/src/texas-holdem.js b/src/texas-holdem.js index ec99a96..5bec035 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -103,6 +103,21 @@ class TexasHoldem { return handEnded; } + // Private: Adds players to the hand if they have enough chips and determines + // small blind and big blind indices. + // + // Returns nothing + setupPlayers() { + for (let player of this.players) { + player.isInHand = player.chips > 0; + player.isBettor = false; + } + + this.currentPot = 0; + this.smallBlindIdx = PlayerOrder.getNextPlayerIndex(this.dealerButton, this.players); + this.bigBlindIdx = PlayerOrder.getNextPlayerIndex(this.smallBlindIdx, this.players); + } + // Private: Handles the logic for a round of betting. // // round - The name of the betting round, e.g., 'preflop', 'flop', 'turn' @@ -110,7 +125,7 @@ class TexasHoldem { // Returns an {Observable} signaling the completion of the round doBettingRound(round) { // NB: If every player is already all-in, end this round early. - let playersRemaining = _.filter(this.players, p => p.isInHand); + let playersRemaining = this.getPlayersInHand(); if (_.every(playersRemaining, p => p.isAllIn)) { let result = { isHandComplete: false }; return rx.Observable.return(result); @@ -149,7 +164,7 @@ class TexasHoldem { // // Returns nothing resetPlayersForBetting(round, previousActions) { - for (let player of this.orderedPlayers) { + for (let player of this.players) { player.lastAction = null; player.isBettor = false; player.hasOption = false; @@ -291,7 +306,7 @@ class TexasHoldem { let everyoneActed = PlayerOrder.isLastToAct(player, this.orderedPlayers); player.isInHand = false; - let playersRemaining = _.filter(this.players, p => p.isInHand); + let playersRemaining = this.getPlayersInHand(); if (playersRemaining.length === 1) { let result = { @@ -456,10 +471,13 @@ class TexasHoldem { let message = ''; if (result.isSplitPot) { _.each(result.winners, winner => { - if (_.last(result.winners) !== winner) + if (_.last(result.winners) !== winner) { message += `${winner.name}, `; - else + winner.chips += Math.floor(this.currentPot / result.winners.length); + } else { message += `and ${winner.name} split the pot`; + winner.chips += Math.ceil(this.currentPot / result.winners.length); + } }); message += ` with ${result.handName}: ${result.hand.toString()}.`; } else { @@ -480,28 +498,13 @@ class TexasHoldem { handEnded.onCompleted(); } - // Private: Adds players to the hand if they have enough chips and determines - // small blind and big blind indices. - // - // Returns nothing - setupPlayers() { - for (let player of this.players) { - player.isInHand = true; - player.isBettor = false; - } - - this.currentPot = 0; - this.smallBlindIdx = (this.dealerButton + 1) % this.players.length; - this.bigBlindIdx = (this.smallBlindIdx + 1) % this.players.length; - } - // Private: Deals hole cards to each player in the game. To communicate this // to the players, we send them a DM with the text description of the cards. // We can't post in channel for obvious reasons. // // Returns nothing dealPlayerCards() { - this.orderedPlayers = PlayerOrder.determine(this.players, this.dealerButton, 'deal'); + this.orderedPlayers = PlayerOrder.determine(this.getPlayersInHand(), this.dealerButton, 'deal'); for (let player of this.orderedPlayers) { let card = this.deck.drawCard(); diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index db15947..8923f10 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -116,7 +116,10 @@ describe('TexasHoldem', function() { var winner = game.lastHandResult.winners[0]; assert(winner.id === 1 || winner.id === 4); + + // Check that the losing player was eliminated. assert(game.board.length === 0); + assert(game.getPlayersInHand().length === 4); game.quit(); }); From db2abe9360478cfe00274c0219a4aeacbadae9df Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 13:54:30 -0700 Subject: [PATCH 14/16] :memo: some undocumented methods. --- src/texas-holdem.js | 88 +++++++++++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 5bec035..4370e5f 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -237,34 +237,6 @@ class TexasHoldem { }); } - validatePlayerAction(player, action) { - if (action.name === 'bet' || action.name === 'raise') { - // If another player has bet, the default raise is 2x. Otherwise the - // minimum bet is 1 small blind. - if (isNaN(action.amount)) { - action.amount = this.currentBet ? - this.currentBet * 2 : - this.smallBlind; - } - - if (action.amount >= player.chips) { - action.amount = player.chips; - } - } - } - - postActionToChannel(player, action) { - let message = `${player.name} ${action.name}s`; - if (action.name === 'bet') - message += ` $${action.amount}.`; - else if (action.name === 'raise') - message += ` to $${action.amount}.`; - else - message += '.'; - - this.channel.send(message); - } - // Private: Occurs when a player action is received. Check the remaining // players and the previous actions, and possibly end the round of betting or // the hand entirely. @@ -393,12 +365,6 @@ class TexasHoldem { } } - everyPlayerTookAction(actions, playerPredicate) { - let playersRemaining = _.filter(this.players, playerPredicate); - return _.every(playersRemaining, p => p.lastAction !== null && - actions.indexOf(p.lastAction.name) > -1); - } - // Private: Displays the flop cards and does a round of betting. If the // betting round results in a winner, end the hand prematurely. Otherwise, // progress to the turn. @@ -552,6 +518,60 @@ class TexasHoldem { return rx.Observable.timer(1000, this.scheduler); }).take(1); } + + // Private: If a player bet or raise, but didn't specify an amount or the + // amount was greater than their chip stack, this will correct it. + // + // player - The acting player + // action - The action that they took + // + // Returns nothing + validatePlayerAction(player, action) { + if (action.name === 'bet' || action.name === 'raise') { + // If another player has bet, the default raise is 2x. Otherwise the + // minimum bet is 1 small blind. + if (isNaN(action.amount)) { + action.amount = this.currentBet ? + this.currentBet * 2 : + this.smallBlind; + } + + if (action.amount >= player.chips) { + action.amount = player.chips; + } + } + } + + // Private: Posts a message to the channel describing a player's action. + // + // player - The acting player + // action - The action that they took + // + // Returns nothing + postActionToChannel(player, action) { + let message = `${player.name} ${action.name}s`; + if (action.name === 'bet') + message += ` $${action.amount}.`; + else if (action.name === 'raise') + message += ` to $${action.amount}.`; + else + message += '.'; + + this.channel.send(message); + } + + // Private: Checks if all player actions adhered to some condition. + // + // actions - An array of strings describing the desired actions + // playerPredicate - A predicate to filter players on + // + // Returns true if every player that meets the predicate took one of the + // desired actions + everyPlayerTookAction(actions, playerPredicate) { + let playersRemaining = _.filter(this.players, playerPredicate); + return _.every(playersRemaining, p => p.lastAction !== null && + actions.indexOf(p.lastAction.name) > -1); + } } module.exports = TexasHoldem; From b8205c964229504ca0a1570dbcc08827d4c90752 Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 14:43:31 -0700 Subject: [PATCH 15/16] Variety of bug fixes. --- ai/aggro-bot.js | 17 ++++++++++++----- ai/weak-bot.js | 2 +- src/player-interaction.js | 13 ++++++++++--- src/texas-holdem.js | 1 + 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ai/aggro-bot.js b/ai/aggro-bot.js index 69131dd..a9a0d89 100644 --- a/ai/aggro-bot.js +++ b/ai/aggro-bot.js @@ -13,10 +13,17 @@ class AggroBot { // TOO STRONK getAction(availableActions, previousActions) { - let action = availableActions.indexOf('raise') > -1 ? - { name: 'raise' } : - { name: 'bet' }; - let delay = 1000 + (Math.random() * 500); - return rx.Observable.timer(delay).map(() => action); + let desiredAction = 'call'; + + if (availableActions.indexOf('raise') > -1) { + desiredAction = 'raise'; + } else if (availableActions.indexOf('bet') > -1) { + desiredAction = 'bet'; + } + + let delay = 2000 + (Math.random() * 2000); + return rx.Observable.timer(delay).map(() => { + return { name: desiredAction }; + }); } }; diff --git a/ai/weak-bot.js b/ai/weak-bot.js index bd1eec2..c7fd8d8 100644 --- a/ai/weak-bot.js +++ b/ai/weak-bot.js @@ -16,7 +16,7 @@ class WeakBot { let action = availableActions.indexOf('check') > -1 ? { name: 'check' } : { name: 'call' }; - let delay = 1000 + (Math.random() * 3000); + let delay = 2000 + (Math.random() * 4000); return rx.Observable.timer(delay).map(() => action); } }; diff --git a/src/player-interaction.js b/src/player-interaction.js index 28509cd..7717e0e 100644 --- a/src/player-interaction.js +++ b/src/player-interaction.js @@ -129,15 +129,15 @@ class PlayerInteraction { // Returns an array of strings static getAvailableActions(player, previousActions) { let actions = _.values(previousActions); - let playerBet = actions.some(a => a.name === 'bet'); - let playerRaised = actions.some(a => a.name === 'raise'); + let betActions = _.filter(actions, a => a.name === 'bet' || a.name === 'raise'); + let hasBet = betActions.length > 0; let availableActions = []; if (player.hasOption) { availableActions.push('check'); availableActions.push('raise'); - } else if (playerBet || playerRaised) { + } else if (hasBet) { availableActions.push('call'); availableActions.push('raise'); } else { @@ -145,6 +145,13 @@ class PlayerInteraction { availableActions.push('bet'); } + // Prevent players from raising when they don't have enough chips. + let raiseIndex = availableActions.indexOf('raise'); + if (raiseIndex > -1 && + _.max(betActions, a => a.amount).amount >= player.chips) { + availableActions.splice(raiseIndex, 1); + } + availableActions.push('fold'); return availableActions; } diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 4370e5f..1cf843f 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -110,6 +110,7 @@ class TexasHoldem { setupPlayers() { for (let player of this.players) { player.isInHand = player.chips > 0; + player.isAllIn = false; player.isBettor = false; } From a069a9dba8205d7d78793041d8813900f22bc3aa Mon Sep 17 00:00:00 2001 From: Charlie Hess Date: Sun, 2 Aug 2015 14:58:02 -0700 Subject: [PATCH 16/16] End the game when we have only one player left. --- src/texas-holdem.js | 28 ++++++++++++++++++++++------ tests/texas-holdem-spec.js | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/texas-holdem.js b/src/texas-holdem.js index 1cf843f..f384d57 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -26,7 +26,7 @@ class TexasHoldem { this.deck = new Deck(); this.smallBlind = 1; this.bigBlind = this.smallBlind * 2; - this.quitGame = new rx.Subject(); + this.gameEnded = new rx.Subject(); // Cache the direct message channels for each player as we'll be using // them often, and fetching them takes linear time per number of users. @@ -48,6 +48,7 @@ class TexasHoldem { // // Returns a {Disposable} that will end this game early start(dealerButton=null, timeBetweenHands=5000) { + this.isRunning = true; this.dealerButton = dealerButton === null ? Math.floor(Math.random() * this.players.length) : dealerButton; @@ -56,16 +57,19 @@ class TexasHoldem { .flatMap(() => this.playHand() .flatMap(() => rx.Observable.timer(timeBetweenHands, this.scheduler))) .repeat() - .takeUntil(this.quitGame) + .takeUntil(this.gameEnded) .subscribe(); } - // Public: Ends the current game immediately and disposes all resources - // associated with the game. + // Public: Ends the current game immediately. // // Returns nothing - quit() { - this.quitGame.onNext(); + quit(winner) { + if (winner) { + this.channel.send(`Congratulations ${winner.name}, you've won!`); + } + this.gameEnded.onNext(winner); + this.isRunning = false; } // Public: Get all players still in the current hand. @@ -463,6 +467,18 @@ class TexasHoldem { handEnded.onNext(true); handEnded.onCompleted(); + this.checkForGameWinner(); + } + + // Private: If there is only one player with chips left, we've got a winner. + // + // Returns nothing + checkForGameWinner() { + let playersWithChips = _.filter(this.players, p => p.chips > 0); + if (playersWithChips.length === 1) { + let winner = playersWithChips[0]; + this.quit(winner); + } } // Private: Deals hole cards to each player in the game. To communicate this diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 8923f10..f18da59 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -46,6 +46,24 @@ describe('TexasHoldem', function() { game.tableFormatter = "\n"; }); + it('should end the game when all players have been eliminated', function() { + game.start(0); + scheduler.advanceBy(5000); + + messages.onNext({user: 4, text: "Raise 200"}); + scheduler.advanceBy(5000); + messages.onNext({user: 5, text: "Call"}); + scheduler.advanceBy(5000); + messages.onNext({user: 1, text: "Call"}); + scheduler.advanceBy(5000); + messages.onNext({user: 2, text: "Call"}); + scheduler.advanceBy(5000); + messages.onNext({user: 3, text: "Call"}); + scheduler.advanceBy(5000); + + assert(!game.isRunning); + }); + it('should handle default bets and raises', function() { game.start(0); scheduler.advanceBy(5000);