diff --git a/ai/aggro-bot.js b/ai/aggro-bot.js index 78e487e..a9a0d89 100644 --- a/ai/aggro-bot.js +++ b/ai/aggro-bot.js @@ -13,8 +13,17 @@ class AggroBot { // TOO STRONK getAction(availableActions, previousActions) { - let action = availableActions.indexOf('raise') > -1 ? 'raise' : '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 7ee66a7..c7fd8d8 100644 --- a/ai/weak-bot.js +++ b/ai/weak-bot.js @@ -13,8 +13,10 @@ class WeakBot { // LOL WEAK getAction(availableActions, previousActions) { - let action = availableActions.indexOf('check') > -1 ? 'check' : 'call'; - let delay = 1000 + (Math.random() * 3000); + let action = availableActions.indexOf('check') > -1 ? + { name: 'check' } : + { name: 'call' }; + 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 913a9b6..7717e0e 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(); @@ -45,17 +45,18 @@ class PlayerInteraction { // // 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, + 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.actionForMessage(e.text, availableActions)) - .where((action) => action !== '') + let playerAction = messages.where(e => e.user === player.id) + .map(e => PlayerInteraction.actionFromMessage(e.text, availableActions)) + .where(action => action !== null) .publish(); playerAction.connect(); @@ -63,18 +64,19 @@ 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' }); + + 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(); - channel.send(`${player.name} ${action}s.`); - }); + .do(() => disp.dispose()); } // Private: Posts a message to the channel with some timeout, that edits @@ -127,15 +129,15 @@ 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 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 { @@ -143,38 +145,65 @@ 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; } - // 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 // - // 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) { + 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 = input[1] ? parseInt(input[1]) : NaN; + break; case 'r': case 'raise': - return 'raise'; + name = 'raise'; + amount = input[1] ? parseInt(input[1]) : NaN; + break; default: - return ''; + return null; } + + // NB: Unavailable actions are always invalid. + return availableActions.indexOf(name) > -1 ? + { name: name, amount: amount } : + null; } } 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 8e2d48c..3089688 100644 --- a/src/player-status.js +++ b/src/player-status.js @@ -2,12 +2,13 @@ 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 // 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++) { @@ -25,25 +26,34 @@ 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); - 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); - - row.push(player.lastAction || ''); + let bigBlindText = idx === bigBlind ? 'Ⓑ' : null; + let positionIndicator = bigBlindText || smallBlindText || dealerText || ' '; + row.push(positionIndicator); + + 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); } 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 2755780..f384d57 100644 --- a/src/texas-holdem.js +++ b/src/texas-holdem.js @@ -23,15 +23,20 @@ class TexasHoldem { this.players = players; this.scheduler = scheduler; + this.deck = new Deck(); + this.smallBlind = 1; + this.bigBlind = this.smallBlind * 2; + 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. this.playerDms = {}; for (let player of this.players) { this.playerDms[player.id] = this.slack.getDMByName(player.name); - } - this.deck = new Deck(); - this.quitGame = new rx.Subject(); + // Each player starts with 100 big blinds. + player.chips = this.bigBlind * 100; + } } // Public: Starts a new game. @@ -43,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; @@ -51,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. @@ -98,12 +107,35 @@ 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.isAllIn = false; + 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' // // 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 = this.getPlayersInHand(); + 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(); @@ -114,7 +146,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) => { @@ -137,21 +169,39 @@ 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; } + this.currentBet = 0; + + 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 bigBlind = this.players[this.bigBlind]; - bigBlind.isBettor = true; - bigBlind.hasOption = true; - previousActions[bigBlind.id] = 'bet'; - } + this.onPlayerBet(sbPlayer, this.smallBlind); + this.onPlayerBet(bbPlayer, this.bigBlind); + 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, @@ -167,19 +217,26 @@ 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.bigBlind, this.smallBlind, 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; - return PlayerInteraction.getActionForPlayer(this.messages, this.channel, player, previousActions, this.scheduler) - .map((action) => { + return PlayerInteraction.getActionForPlayer(this.messages, this.channel, + 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; previousActions[player.id] = action; - return {player: player, action: action}; + return { player: player, action: action }; }); }); }); @@ -196,7 +253,7 @@ class TexasHoldem { // // Returns nothing onPlayerAction(player, action, previousActions, roundEnded) { - switch (action) { + switch (action.name) { case 'fold': this.onPlayerFolded(player, roundEnded); break; @@ -208,7 +265,7 @@ class TexasHoldem { break; case 'bet': case 'raise': - this.onPlayerBet(player); + this.onPlayerBet(player, action.amount); break; } } @@ -226,7 +283,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 = { @@ -249,9 +306,7 @@ class TexasHoldem { // // Returns nothing onPlayerChecked(player, previousActions, roundEnded) { - let playersRemaining = _.filter(this.players, p => p.isInHand); - let everyoneChecked = _.every(playersRemaining, p => - p.lastAction === 'check' || p.lastAction === 'call'); + let everyoneChecked = this.everyPlayerTookAction(['check', 'call'], p => p.isInHand); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneChecked && everyoneHadATurn) { @@ -268,8 +323,9 @@ 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'); + this.updatePlayerChips(player, this.currentBet); + + let everyoneCalled = this.everyPlayerTookAction(['call'], p => p.isInHand && !p.isBettor); let everyoneHadATurn = PlayerOrder.isLastToAct(player, this.orderedPlayers); if (everyoneCalled && everyoneHadATurn) { @@ -282,15 +338,36 @@ class TexasHoldem { // 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) { + if (player.chips <= amount) { + player.isAllIn = true; + this.currentPot += player.chips; + player.chips = 0; + } else { + player.chips -= amount; + this.currentPot += amount; + } } // Private: Displays the flop cards and does a round of betting. If the @@ -365,19 +442,23 @@ 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 { - 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); @@ -386,20 +467,18 @@ class TexasHoldem { handEnded.onNext(true); handEnded.onCompleted(); + this.checkForGameWinner(); } - // Private: Adds players to the hand if they have enough chips and posts - // blinds. + // Private: If there is only one player with chips left, we've got a winner. // // Returns nothing - setupPlayers() { - for (let player of this.players) { - player.isInHand = true; - player.isBettor = false; + checkForGameWinner() { + let playersWithChips = _.filter(this.players, p => p.chips > 0); + if (playersWithChips.length === 1) { + let winner = playersWithChips[0]; + this.quit(winner); } - - this.smallBlind = (this.dealerButton + 1) % this.players.length; - this.bigBlind = (this.smallBlind + 1) % this.players.length; } // Private: Deals hole cards to each player in the game. To communicate this @@ -408,7 +487,7 @@ class TexasHoldem { // // 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(); @@ -445,7 +524,7 @@ class TexasHoldem { title: `Dealing the ${round}:`, fallback: this.board.toString(), text: this.board.toString(), - color: "good", + color: 'good', image_url: url }]; @@ -456,6 +535,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; diff --git a/tests/player-interaction-spec.js b/tests/player-interaction-spec.js index 3dfa406..eff673c 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); + assert(action.name === 'bet'); + assert(isNaN(action.amount)); + + 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(isNaN(action.amount)); + + 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(isNaN(action.amount)); + + 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); + }); + }); +}); diff --git a/tests/texas-holdem-spec.js b/tests/texas-holdem-spec.js index 69b8f03..f18da59 100644 --- a/tests/texas-holdem-spec.js +++ b/tests/texas-holdem-spec.js @@ -46,6 +46,101 @@ 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); + + 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); + + 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 200"}); + scheduler.advanceBy(5000); + + assert(game.currentBet === 198); + assert(players[3].chips === 0); + assert(players[3].isAllIn); + + messages.onNext({user: 1, text: "Call"}); + assert(game.currentPot === 403); + scheduler.advanceBy(5000); + + 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(); + }); + it('should handle split pots correctly', function() { game.start(0); scheduler.advanceBy(5000); @@ -192,19 +287,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.