UNPKG

@vlad-yakovlev/poker

Version:
360 lines (359 loc) 12.9 kB
import { EventEmitter } from 'eventemitter3'; import * as R from 'remeda'; import { ERROR_CODE } from '../types/error.js'; import { ROUND } from '../types/state.js'; import { shuffle } from '../utils/shuffle.js'; import { BaseError } from './BaseError.js'; import { Player } from './Player.js'; export class Room extends EventEmitter { storage; startingBaseBetAmount; id; cards; round; dealsCount; dealerIndex; currentPlayerIndex; players; payload; constructor(params, roomData) { super(); this.storage = params.storage; this.startingBaseBetAmount = params.startingBaseBetAmount; this.id = roomData.id; this.cards = roomData.cards; this.round = roomData.round; this.dealsCount = roomData.dealsCount; this.dealerIndex = roomData.dealerIndex; this.currentPlayerIndex = roomData.currentPlayerIndex; this.players = roomData.players.map((playerData) => new Player(this, playerData)); this.payload = roomData.payload; } /** * Create a new room */ // eslint-disable-next-line @typescript-eslint/no-explicit-any static async create(id, params, payload) { const roomData = { id, cards: [], round: ROUND.PREFLOP, dealsCount: 0, dealerIndex: 0, currentPlayerIndex: 0, players: [], payload, }; await params.storage.set(id, roomData); return new Room(params, roomData); } /** * Load room from storage */ // eslint-disable-next-line @typescript-eslint/no-explicit-any static async load(id, params) { const roomData = await params.storage.get(id); if (!roomData) return; return new Room(params, roomData); } async save() { await this.storage.set(this.id, { id: this.id, cards: this.cards, round: this.round, dealsCount: this.dealsCount, dealerIndex: this.dealerIndex, currentPlayerIndex: this.currentPlayerIndex, players: this.players.map((player) => ({ id: player.id, cards: player.cards, balance: player.balance, betAmount: player.betAmount, hasFolded: player.hasFolded, hasLost: player.hasLost, hasTurned: player.hasTurned, payload: player.payload, })), payload: this.payload, }); } get potAmount() { return this.players.reduce((acc, player) => acc + player.betAmount, 0); } /** * Bet amount for current deal */ get baseBetAmount() { return (Math.floor(this.dealsCount / 4) + 1) * this.startingBaseBetAmount; } /** * Amount that each player should have bet in order to continue playing */ get requiredBetAmount() { return Math.max(...this.players.map((player) => player.betAmount)); } get currentPlayer() { return this.players[this.currentPlayerIndex]; } /** * Add player to room */ async addPlayer(id, balance, payload) { this.players.push(new Player(this, { id, cards: [], balance, betAmount: 0, hasFolded: false, hasLost: false, hasTurned: false, payload, })); await this.save(); } /** * Deal cards. Used to start a game */ async dealCards() { const deck = shuffle(R.range(0, 52)); this.dealsCount++; this.round = ROUND.PREFLOP; this.cards = deck.splice(0, 5); this.players.forEach((player) => { player.betAmount = 0; player.cards = deck.splice(0, 2); player.hasFolded = false; player.hasTurned = false; }); this.dealerIndex = this.getNextPlayerIndex(this.dealerIndex); const smallBlindIndex = this.getNextPlayerIndex(this.dealerIndex); const bigBlindIndex = this.getNextPlayerIndex(smallBlindIndex); this.currentPlayerIndex = bigBlindIndex; this.players[smallBlindIndex].increaseBet(this.baseBetAmount / 2); this.players[bigBlindIndex].increaseBet(this.baseBetAmount); await this.save(); this.emit('nextDeal', { players: this.players.filter((player) => !player.hasLost), dealer: this.players[this.dealerIndex], smallBlind: this.players[smallBlindIndex], bigBlind: this.players[bigBlindIndex], }); await this.nextTurn(); } /** * End game. Used to stop a game early */ async endGame() { this.emit('gameEnded'); await this.storage.delete(this.id); } /** * Perform a fold action for a given player */ async fold(playerId) { if (this.currentPlayer.id !== playerId) { throw new BaseError(ERROR_CODE.WRONG_TURN); } if (!this.currentPlayer.canFold) { throw new BaseError(ERROR_CODE.FOLD_NOT_ALLOWED); } this.currentPlayer.hasFolded = true; this.currentPlayer.hasTurned = true; this.emit('fold', { player: this.currentPlayer }); await this.nextTurn(); } /** * Perform a check action for a given player */ async check(playerId) { if (this.currentPlayer.id !== playerId) { throw new BaseError(ERROR_CODE.WRONG_TURN); } if (!this.currentPlayer.canCheck) { throw new BaseError(ERROR_CODE.CHECK_NOT_ALLOWED); } this.currentPlayer.hasTurned = true; this.emit('check', { player: this.currentPlayer }); await this.nextTurn(); } /** * Perform a call action for a given player */ async call(playerId) { if (this.currentPlayer.id !== playerId) { throw new BaseError(ERROR_CODE.WRONG_TURN); } if (!this.currentPlayer.canCall) { throw new BaseError(ERROR_CODE.CALL_NOT_ALLOWED); } this.currentPlayer.increaseBet(this.currentPlayer.callAmount); this.currentPlayer.hasTurned = true; this.emit('call', { player: this.currentPlayer }); await this.nextTurn(); } /** * Perform a raise action for a given player with a given amount */ async raise(playerId, amount) { if (this.currentPlayer.id !== playerId) { throw new BaseError(ERROR_CODE.WRONG_TURN); } if (!this.currentPlayer.canRaise) { throw new BaseError(ERROR_CODE.RAISE_NOT_ALLOWED); } if (amount < this.currentPlayer.minRaiseAmount) { throw new BaseError(ERROR_CODE.RAISE_AMOUNT_TOO_SMALL); } if (amount > this.currentPlayer.maxRaiseAmount) { throw new BaseError(ERROR_CODE.RAISE_AMOUNT_TOO_BIG); } this.currentPlayer.increaseBet(this.currentPlayer.callAmount + amount); this.currentPlayer.hasTurned = true; this.emit('raise', { player: this.currentPlayer, amount }); await this.nextTurn(); } /** * Perform an all-in action for a given player */ async allIn(playerId) { if (this.currentPlayer.id !== playerId) { throw new BaseError(ERROR_CODE.WRONG_TURN); } if (!this.currentPlayer.canAllIn) { throw new BaseError(ERROR_CODE.ALL_IN_NOT_ALLOWED); } this.currentPlayer.increaseBet(this.currentPlayer.balance); this.currentPlayer.hasTurned = true; this.emit('allIn', { player: this.currentPlayer }); await this.nextTurn(); } async nextTurn() { const playersInDeal = this.players.filter((player) => !player.hasLost && !player.hasFolded); const playersWithBalance = playersInDeal.filter((player) => player.balance); const playersWhoNeedToTurn = playersWithBalance.filter((player) => !player.hasTurned || player.betAmount !== this.requiredBetAmount); if (playersInDeal.length < 2) { return this.endDeal(); } if (!playersWhoNeedToTurn.length) { if (this.round === ROUND.RIVER || playersWithBalance.length < 2) { return this.endDeal(); } this.players.forEach((player) => void (player.hasTurned = false)); this.round = this.getNextRound(this.round); this.currentPlayerIndex = this.dealerIndex; } this.currentPlayerIndex = this.getNextPlayerIndex(this.currentPlayerIndex); await this.save(); this.emit('nextTurn', { player: this.currentPlayer }); } async endDeal() { const winners = new Map(); // Loops until no one has any bets left. Used to properly count all-ins while (this.potAmount) { let playersWithBetsCount = 0; let minBetAmount = Infinity; let bestCombinationWeight = 0; const currentWinners = new Set(); // Calculates minimum bet amount and finds winners with bets left this.players.forEach((player) => { if (!player.betAmount) return; playersWithBetsCount++; if (player.betAmount < minBetAmount) { minBetAmount = player.betAmount; } if (player.hasLost || player.hasFolded) return; const weight = player.bestCombination?.weight ?? 0; if (weight > bestCombinationWeight) { bestCombinationWeight = weight; currentWinners.clear(); currentWinners.add(player.id); } else if (weight === bestCombinationWeight) { currentWinners.add(player.id); } }); const wonAmount = (minBetAmount * playersWithBetsCount) / currentWinners.size; // Updates bets and winners this.players.forEach((player) => { if (player.betAmount) { player.betAmount -= minBetAmount; } if (currentWinners.has(player.id)) { player.balance += wonAmount; winners.set(player.id, (winners.get(player.id) ?? 0) + wonAmount); } }); } // Creates a copy of the room to emit the dealEnded event with the correct data const roomCopy = new Room({ storage: { get: () => Promise.resolve(undefined), set: () => Promise.resolve(), delete: () => Promise.resolve(), }, startingBaseBetAmount: this.startingBaseBetAmount, }, { id: this.id, cards: this.cards, round: this.round, dealsCount: this.dealsCount, dealerIndex: this.dealerIndex, currentPlayerIndex: this.currentPlayerIndex, players: this.players.map((player) => ({ id: player.id, cards: player.cards, balance: player.balance, betAmount: player.betAmount, hasFolded: player.hasFolded, hasLost: player.hasLost, hasTurned: player.hasTurned, payload: player.payload, })), payload: this.payload, }); this.emit('dealEnded', { tableCards: roomCopy.cards, players: roomCopy.players .filter((player) => !player.hasLost) .map((player) => ({ player, wonAmount: winners.get(player.id) ?? 0, })), }); // Marks players who have lost this.players.forEach((player) => { if (player.balance === 0) { player.hasLost = true; } }); // Ends the game if there are less than 2 players left, or starts a new deal if (this.players.filter((player) => !player.hasLost).length < 2) { await this.endGame(); } else { await this.dealCards(); } } getNextPlayerIndex(index) { do { index += 1; index %= this.players.length; } while (this.players[index].hasLost || this.players[index].hasFolded || !this.players[index].balance); return index; } getNextRound(round) { switch (round) { case ROUND.PREFLOP: return ROUND.FLOP; case ROUND.FLOP: return ROUND.TURN; default: return ROUND.RIVER; } } }