2150 lines
38 KiB
JavaScript
2150 lines
38 KiB
JavaScript
const SUITS = [
|
|
"spades",
|
|
"hearts",
|
|
"diamonds",
|
|
"clubs"
|
|
];
|
|
|
|
const RANKS = [
|
|
"2",
|
|
"3",
|
|
"4",
|
|
"5",
|
|
"6",
|
|
"7",
|
|
"8",
|
|
"9",
|
|
"10",
|
|
"J",
|
|
"Q",
|
|
"K",
|
|
"A"
|
|
];
|
|
|
|
const RANK_VALUES = {
|
|
"2": 2,
|
|
"3": 3,
|
|
"4": 4,
|
|
"5": 5,
|
|
"6": 6,
|
|
"7": 7,
|
|
"8": 8,
|
|
"9": 9,
|
|
"10": 10,
|
|
J: 11,
|
|
Q: 12,
|
|
K: 13,
|
|
A: 14
|
|
};
|
|
|
|
const HAND_CATEGORIES = {
|
|
HIGH_CARD: 0,
|
|
PAIR: 1,
|
|
TWO_PAIR: 2,
|
|
THREE_OF_A_KIND: 3,
|
|
STRAIGHT: 4,
|
|
FLUSH: 5,
|
|
FULL_HOUSE: 6,
|
|
FOUR_OF_A_KIND: 7,
|
|
STRAIGHT_FLUSH: 8,
|
|
ROYAL_FLUSH: 9
|
|
};
|
|
|
|
const HAND_CATEGORY_NAMES = {
|
|
[HAND_CATEGORIES.HIGH_CARD]:
|
|
"High Card",
|
|
[HAND_CATEGORIES.PAIR]:
|
|
"Pair",
|
|
[HAND_CATEGORIES.TWO_PAIR]:
|
|
"Two Pair",
|
|
[HAND_CATEGORIES.THREE_OF_A_KIND]:
|
|
"Three of a Kind",
|
|
[HAND_CATEGORIES.STRAIGHT]:
|
|
"Straight",
|
|
[HAND_CATEGORIES.FLUSH]:
|
|
"Flush",
|
|
[HAND_CATEGORIES.FULL_HOUSE]:
|
|
"Full House",
|
|
[HAND_CATEGORIES.FOUR_OF_A_KIND]:
|
|
"Four of a Kind",
|
|
[HAND_CATEGORIES.STRAIGHT_FLUSH]:
|
|
"Straight Flush",
|
|
[HAND_CATEGORIES.ROYAL_FLUSH]:
|
|
"Royal Flush"
|
|
};
|
|
|
|
const STREETS = [
|
|
"PREFLOP",
|
|
"FLOP",
|
|
"TURN",
|
|
"RIVER"
|
|
];
|
|
|
|
class PokerEngineError extends Error {
|
|
constructor(
|
|
code,
|
|
message
|
|
) {
|
|
super(message);
|
|
this.name =
|
|
"PokerEngineError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function createDeck() {
|
|
const deck = [];
|
|
|
|
for (const suit of SUITS) {
|
|
for (const rank of RANKS) {
|
|
deck.push({
|
|
rank,
|
|
suit
|
|
});
|
|
}
|
|
}
|
|
|
|
return deck;
|
|
}
|
|
|
|
function shuffleDeck(
|
|
deck,
|
|
rng = Math.random
|
|
) {
|
|
const shuffled =
|
|
deck.map(card => ({
|
|
...card
|
|
}));
|
|
|
|
for (
|
|
let index =
|
|
shuffled.length - 1;
|
|
index > 0;
|
|
index--
|
|
) {
|
|
const swapIndex =
|
|
Math.floor(
|
|
rng() *
|
|
(index + 1)
|
|
);
|
|
|
|
[
|
|
shuffled[index],
|
|
shuffled[swapIndex]
|
|
] = [
|
|
shuffled[swapIndex],
|
|
shuffled[index]
|
|
];
|
|
}
|
|
|
|
return shuffled;
|
|
}
|
|
|
|
function cardToString(
|
|
card
|
|
) {
|
|
if (!card) {
|
|
return "";
|
|
}
|
|
|
|
const symbols = {
|
|
spades: "♠",
|
|
hearts: "♥",
|
|
diamonds: "♦",
|
|
clubs: "♣"
|
|
};
|
|
|
|
return (
|
|
`${card.rank}${symbols[card.suit] ?? "?"}`
|
|
);
|
|
}
|
|
|
|
function cloneState(
|
|
state
|
|
) {
|
|
if (
|
|
typeof structuredClone ===
|
|
"function"
|
|
) {
|
|
return structuredClone(
|
|
state
|
|
);
|
|
}
|
|
|
|
return JSON.parse(
|
|
JSON.stringify(state)
|
|
);
|
|
}
|
|
|
|
function combinations(
|
|
items,
|
|
choose
|
|
) {
|
|
const output = [];
|
|
|
|
function walk(
|
|
start,
|
|
current
|
|
) {
|
|
if (
|
|
current.length ===
|
|
choose
|
|
) {
|
|
output.push(
|
|
current.slice()
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (
|
|
let index = start;
|
|
index < items.length;
|
|
index++
|
|
) {
|
|
current.push(
|
|
items[index]
|
|
);
|
|
|
|
walk(
|
|
index + 1,
|
|
current
|
|
);
|
|
|
|
current.pop();
|
|
}
|
|
}
|
|
|
|
walk(
|
|
0,
|
|
[]
|
|
);
|
|
|
|
return output;
|
|
}
|
|
|
|
function evaluateFiveCardHand(
|
|
cards
|
|
) {
|
|
if (
|
|
!Array.isArray(cards) ||
|
|
cards.length !== 5
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_HAND",
|
|
"Exactly five cards are required."
|
|
);
|
|
}
|
|
|
|
const values =
|
|
cards
|
|
.map(
|
|
card =>
|
|
RANK_VALUES[card.rank]
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
b - a
|
|
);
|
|
|
|
if (
|
|
values.some(
|
|
value =>
|
|
!Number.isInteger(value)
|
|
)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_CARD",
|
|
"A card has an invalid rank."
|
|
);
|
|
}
|
|
|
|
const flush =
|
|
cards.every(
|
|
card =>
|
|
card.suit ===
|
|
cards[0].suit
|
|
);
|
|
|
|
const uniqueValues =
|
|
[...new Set(values)];
|
|
|
|
let straightHigh = null;
|
|
|
|
if (
|
|
uniqueValues.length === 5
|
|
) {
|
|
if (
|
|
uniqueValues[0] -
|
|
uniqueValues[4] ===
|
|
4
|
|
) {
|
|
straightHigh =
|
|
uniqueValues[0];
|
|
}
|
|
|
|
else if (
|
|
uniqueValues.join(",") ===
|
|
"14,5,4,3,2"
|
|
) {
|
|
straightHigh = 5;
|
|
}
|
|
}
|
|
|
|
const counts =
|
|
new Map();
|
|
|
|
for (const value of values) {
|
|
counts.set(
|
|
value,
|
|
(counts.get(value) ?? 0) + 1
|
|
);
|
|
}
|
|
|
|
const groups =
|
|
[...counts.entries()]
|
|
.map(
|
|
([value, count]) => ({
|
|
value,
|
|
count
|
|
})
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
b.count - a.count ||
|
|
b.value - a.value
|
|
);
|
|
|
|
if (
|
|
flush &&
|
|
straightHigh !== null
|
|
) {
|
|
if (
|
|
straightHigh === 14
|
|
) {
|
|
return buildRank(
|
|
HAND_CATEGORIES.ROYAL_FLUSH,
|
|
[14],
|
|
cards
|
|
);
|
|
}
|
|
|
|
return buildRank(
|
|
HAND_CATEGORIES.STRAIGHT_FLUSH,
|
|
[straightHigh],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
groups[0].count === 4
|
|
) {
|
|
return buildRank(
|
|
HAND_CATEGORIES.FOUR_OF_A_KIND,
|
|
[
|
|
groups[0].value,
|
|
groups[1].value
|
|
],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
groups[0].count === 3 &&
|
|
groups[1].count === 2
|
|
) {
|
|
return buildRank(
|
|
HAND_CATEGORIES.FULL_HOUSE,
|
|
[
|
|
groups[0].value,
|
|
groups[1].value
|
|
],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (flush) {
|
|
return buildRank(
|
|
HAND_CATEGORIES.FLUSH,
|
|
values,
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
straightHigh !== null
|
|
) {
|
|
return buildRank(
|
|
HAND_CATEGORIES.STRAIGHT,
|
|
[straightHigh],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
groups[0].count === 3
|
|
) {
|
|
const kickers =
|
|
groups
|
|
.slice(1)
|
|
.map(
|
|
group =>
|
|
group.value
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
b - a
|
|
);
|
|
|
|
return buildRank(
|
|
HAND_CATEGORIES.THREE_OF_A_KIND,
|
|
[
|
|
groups[0].value,
|
|
...kickers
|
|
],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
groups[0].count === 2 &&
|
|
groups[1].count === 2
|
|
) {
|
|
const pairs =
|
|
[
|
|
groups[0].value,
|
|
groups[1].value
|
|
].sort(
|
|
(a, b) =>
|
|
b - a
|
|
);
|
|
|
|
const kicker =
|
|
groups.find(
|
|
group =>
|
|
group.count === 1
|
|
).value;
|
|
|
|
return buildRank(
|
|
HAND_CATEGORIES.TWO_PAIR,
|
|
[
|
|
...pairs,
|
|
kicker
|
|
],
|
|
cards
|
|
);
|
|
}
|
|
|
|
if (
|
|
groups[0].count === 2
|
|
) {
|
|
const kickers =
|
|
groups
|
|
.slice(1)
|
|
.map(
|
|
group =>
|
|
group.value
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
b - a
|
|
);
|
|
|
|
return buildRank(
|
|
HAND_CATEGORIES.PAIR,
|
|
[
|
|
groups[0].value,
|
|
...kickers
|
|
],
|
|
cards
|
|
);
|
|
}
|
|
|
|
return buildRank(
|
|
HAND_CATEGORIES.HIGH_CARD,
|
|
values,
|
|
cards
|
|
);
|
|
}
|
|
|
|
function buildRank(
|
|
category,
|
|
tiebreak,
|
|
cards
|
|
) {
|
|
return {
|
|
category,
|
|
name:
|
|
HAND_CATEGORY_NAMES[
|
|
category
|
|
],
|
|
tiebreak,
|
|
cards:
|
|
cards.map(
|
|
card => ({
|
|
...card
|
|
})
|
|
)
|
|
};
|
|
}
|
|
|
|
function compareHandRanks(
|
|
left,
|
|
right
|
|
) {
|
|
if (
|
|
left.category !==
|
|
right.category
|
|
) {
|
|
return (
|
|
left.category >
|
|
right.category
|
|
? 1
|
|
: -1
|
|
);
|
|
}
|
|
|
|
const length =
|
|
Math.max(
|
|
left.tiebreak.length,
|
|
right.tiebreak.length
|
|
);
|
|
|
|
for (
|
|
let index = 0;
|
|
index < length;
|
|
index++
|
|
) {
|
|
const leftValue =
|
|
left.tiebreak[index] ?? 0;
|
|
|
|
const rightValue =
|
|
right.tiebreak[index] ?? 0;
|
|
|
|
if (
|
|
leftValue !==
|
|
rightValue
|
|
) {
|
|
return (
|
|
leftValue > rightValue
|
|
? 1
|
|
: -1
|
|
);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function evaluateHand(
|
|
cards
|
|
) {
|
|
if (
|
|
!Array.isArray(cards) ||
|
|
cards.length < 5 ||
|
|
cards.length > 7
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_HAND",
|
|
"A poker hand must contain between five and seven cards."
|
|
);
|
|
}
|
|
|
|
let best = null;
|
|
|
|
for (
|
|
const candidate
|
|
of combinations(
|
|
cards,
|
|
5
|
|
)
|
|
) {
|
|
const rank =
|
|
evaluateFiveCardHand(
|
|
candidate
|
|
);
|
|
|
|
if (
|
|
!best ||
|
|
compareHandRanks(
|
|
rank,
|
|
best
|
|
) > 0
|
|
) {
|
|
best = rank;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
function splitPot(
|
|
pot,
|
|
winnerSeats,
|
|
dealerSeat = -1
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(pot) ||
|
|
pot < 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_POT",
|
|
"Pot must be a non-negative safe integer."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!Array.isArray(winnerSeats) ||
|
|
winnerSeats.length === 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"NO_WINNERS",
|
|
"At least one winner is required."
|
|
);
|
|
}
|
|
|
|
const uniqueSeats =
|
|
[...new Set(winnerSeats)]
|
|
.sort(
|
|
(a, b) =>
|
|
a - b
|
|
);
|
|
|
|
const base =
|
|
Math.floor(
|
|
pot /
|
|
uniqueSeats.length
|
|
);
|
|
|
|
let remainder =
|
|
pot % uniqueSeats.length;
|
|
|
|
const ordered =
|
|
orderSeatsAfter(
|
|
uniqueSeats,
|
|
dealerSeat
|
|
);
|
|
|
|
const payouts = {};
|
|
|
|
for (const seat of ordered) {
|
|
payouts[seat] = base;
|
|
}
|
|
|
|
for (
|
|
let index = 0;
|
|
remainder > 0;
|
|
index++, remainder--
|
|
) {
|
|
const seat =
|
|
ordered[
|
|
index %
|
|
ordered.length
|
|
];
|
|
|
|
payouts[seat]++;
|
|
}
|
|
|
|
return payouts;
|
|
}
|
|
|
|
function resolveShowdown(
|
|
state
|
|
) {
|
|
const active =
|
|
state.players.filter(
|
|
player =>
|
|
!player.folded
|
|
);
|
|
|
|
if (
|
|
active.length === 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"NO_ACTIVE_PLAYERS",
|
|
"Showdown has no active players."
|
|
);
|
|
}
|
|
|
|
let winners = [];
|
|
let bestRank = null;
|
|
|
|
for (const player of active) {
|
|
const rank =
|
|
evaluateHand([
|
|
...player.holeCards,
|
|
...state.communityCards
|
|
]);
|
|
|
|
player.handRank = rank;
|
|
|
|
if (!bestRank) {
|
|
bestRank = rank;
|
|
winners = [player];
|
|
continue;
|
|
}
|
|
|
|
const comparison =
|
|
compareHandRanks(
|
|
rank,
|
|
bestRank
|
|
);
|
|
|
|
if (comparison > 0) {
|
|
bestRank = rank;
|
|
winners = [player];
|
|
}
|
|
|
|
else if (
|
|
comparison === 0
|
|
) {
|
|
winners.push(player);
|
|
}
|
|
}
|
|
|
|
const payouts =
|
|
splitPot(
|
|
state.pot,
|
|
winners.map(
|
|
player =>
|
|
player.seat
|
|
),
|
|
state.dealerSeat
|
|
);
|
|
|
|
for (const player of state.players) {
|
|
const payout =
|
|
payouts[player.seat] ?? 0;
|
|
|
|
player.stack += payout;
|
|
}
|
|
|
|
state.result = {
|
|
type:
|
|
"showdown",
|
|
winnerSeats:
|
|
winners.map(
|
|
player =>
|
|
player.seat
|
|
),
|
|
payouts,
|
|
handRank:
|
|
bestRank
|
|
};
|
|
|
|
state.pot = 0;
|
|
state.state =
|
|
"PAYOUT";
|
|
state.currentSeat = null;
|
|
|
|
return state;
|
|
}
|
|
|
|
function startHand({
|
|
players,
|
|
dealerSeat = null,
|
|
smallBlind = 100,
|
|
bigBlind = 200,
|
|
deck = null
|
|
}) {
|
|
validatePlayers(
|
|
players
|
|
);
|
|
|
|
if (
|
|
!Number.isSafeInteger(
|
|
smallBlind
|
|
) ||
|
|
!Number.isSafeInteger(
|
|
bigBlind
|
|
) ||
|
|
smallBlind <= 0 ||
|
|
bigBlind <= 0 ||
|
|
smallBlind >= bigBlind
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_BLINDS",
|
|
"Blinds must be positive integers and the small blind must be lower than the big blind."
|
|
);
|
|
}
|
|
|
|
const sortedPlayers =
|
|
players
|
|
.map(
|
|
player => ({
|
|
userId:
|
|
String(
|
|
player.userId
|
|
),
|
|
seat:
|
|
Number(
|
|
player.seat
|
|
),
|
|
stack:
|
|
Number(
|
|
player.stack
|
|
)
|
|
})
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
a.seat - b.seat
|
|
);
|
|
|
|
const handCap =
|
|
Math.min(
|
|
...sortedPlayers.map(
|
|
player =>
|
|
player.stack
|
|
)
|
|
);
|
|
|
|
if (
|
|
handCap < bigBlind
|
|
) {
|
|
throw new PokerEngineError(
|
|
"STACK_BELOW_BIG_BLIND",
|
|
"Every player must have at least one big blind available to start a hand."
|
|
);
|
|
}
|
|
|
|
const seats =
|
|
sortedPlayers.map(
|
|
player =>
|
|
player.seat
|
|
);
|
|
|
|
const resolvedDealerSeat =
|
|
dealerSeat === null
|
|
? seats[0]
|
|
: Number(dealerSeat);
|
|
|
|
if (
|
|
!seats.includes(
|
|
resolvedDealerSeat
|
|
)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_DEALER",
|
|
"Dealer seat does not belong to an active player."
|
|
);
|
|
}
|
|
|
|
const workingDeck =
|
|
deck
|
|
? deck.map(
|
|
card => ({
|
|
...card
|
|
})
|
|
)
|
|
: shuffleDeck(
|
|
createDeck()
|
|
);
|
|
|
|
if (
|
|
workingDeck.length <
|
|
sortedPlayers.length * 2 + 5
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INSUFFICIENT_DECK",
|
|
"The deck does not contain enough cards."
|
|
);
|
|
}
|
|
|
|
const state = {
|
|
state:
|
|
"PREFLOP",
|
|
street:
|
|
"PREFLOP",
|
|
players:
|
|
sortedPlayers.map(
|
|
player => ({
|
|
...player,
|
|
startingStack:
|
|
player.stack,
|
|
handCap,
|
|
totalBet: 0,
|
|
streetBet: 0,
|
|
folded: false,
|
|
allIn: false,
|
|
actedThisStreet:
|
|
false,
|
|
holeCards: []
|
|
})
|
|
),
|
|
dealerSeat:
|
|
resolvedDealerSeat,
|
|
smallBlind,
|
|
bigBlind,
|
|
smallBlindSeat: null,
|
|
bigBlindSeat: null,
|
|
currentSeat: null,
|
|
communityCards: [],
|
|
deck:
|
|
workingDeck,
|
|
pot: 0,
|
|
currentBet: 0,
|
|
lastRaiseSize:
|
|
bigBlind,
|
|
handCap,
|
|
result: null
|
|
};
|
|
|
|
dealHoleCards(
|
|
state
|
|
);
|
|
|
|
const headsUp =
|
|
state.players.length === 2;
|
|
|
|
const smallBlindSeat =
|
|
headsUp
|
|
? state.dealerSeat
|
|
: nextSeatAfter(
|
|
state.players,
|
|
state.dealerSeat,
|
|
() => true
|
|
);
|
|
|
|
const bigBlindSeat =
|
|
nextSeatAfter(
|
|
state.players,
|
|
smallBlindSeat,
|
|
() => true
|
|
);
|
|
|
|
state.smallBlindSeat =
|
|
smallBlindSeat;
|
|
state.bigBlindSeat =
|
|
bigBlindSeat;
|
|
|
|
postForcedBet(
|
|
state,
|
|
smallBlindSeat,
|
|
smallBlind
|
|
);
|
|
|
|
postForcedBet(
|
|
state,
|
|
bigBlindSeat,
|
|
bigBlind
|
|
);
|
|
|
|
state.currentBet =
|
|
bigBlind;
|
|
|
|
state.lastRaiseSize =
|
|
bigBlind;
|
|
|
|
state.currentSeat =
|
|
nextSeatAfter(
|
|
state.players,
|
|
bigBlindSeat,
|
|
canPlayerAct
|
|
);
|
|
|
|
if (
|
|
state.currentSeat === null
|
|
) {
|
|
progressUntilActionOrPayout(
|
|
state
|
|
);
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function validatePlayers(
|
|
players
|
|
) {
|
|
if (
|
|
!Array.isArray(players) ||
|
|
players.length < 2 ||
|
|
players.length > 6
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_PLAYER_COUNT",
|
|
"Poker requires between 2 and 6 players."
|
|
);
|
|
}
|
|
|
|
const userIds =
|
|
new Set();
|
|
const seats =
|
|
new Set();
|
|
|
|
for (const player of players) {
|
|
const userId =
|
|
String(
|
|
player?.userId ?? ""
|
|
);
|
|
|
|
const seat =
|
|
Number(
|
|
player?.seat
|
|
);
|
|
|
|
const stack =
|
|
Number(
|
|
player?.stack
|
|
);
|
|
|
|
if (!userId) {
|
|
throw new PokerEngineError(
|
|
"INVALID_PLAYER",
|
|
"Every player needs a userId."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!Number.isSafeInteger(seat) ||
|
|
seat < 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_SEAT",
|
|
"Every player needs a non-negative integer seat."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!Number.isSafeInteger(stack) ||
|
|
stack <= 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_STACK",
|
|
"Every player needs a positive integer stack."
|
|
);
|
|
}
|
|
|
|
if (
|
|
userIds.has(userId)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"DUPLICATE_PLAYER",
|
|
"A player cannot occupy multiple seats."
|
|
);
|
|
}
|
|
|
|
if (
|
|
seats.has(seat)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"DUPLICATE_SEAT",
|
|
"Two players cannot occupy the same seat."
|
|
);
|
|
}
|
|
|
|
userIds.add(userId);
|
|
seats.add(seat);
|
|
}
|
|
}
|
|
|
|
function dealHoleCards(
|
|
state
|
|
) {
|
|
for (
|
|
let round = 0;
|
|
round < 2;
|
|
round++
|
|
) {
|
|
const orderedSeats =
|
|
orderSeatsAfter(
|
|
state.players.map(
|
|
player =>
|
|
player.seat
|
|
),
|
|
state.dealerSeat
|
|
);
|
|
|
|
for (const seat of orderedSeats) {
|
|
const player =
|
|
getPlayerBySeat(
|
|
state,
|
|
seat
|
|
);
|
|
|
|
player.holeCards.push(
|
|
state.deck.pop()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function postForcedBet(
|
|
state,
|
|
seat,
|
|
amount
|
|
) {
|
|
const player =
|
|
getPlayerBySeat(
|
|
state,
|
|
seat
|
|
);
|
|
|
|
const wager =
|
|
Math.min(
|
|
amount,
|
|
remainingHandBudget(
|
|
player
|
|
)
|
|
);
|
|
|
|
commitChips(
|
|
state,
|
|
player,
|
|
wager
|
|
);
|
|
|
|
if (
|
|
remainingHandBudget(
|
|
player
|
|
) === 0
|
|
) {
|
|
player.allIn = true;
|
|
}
|
|
}
|
|
|
|
function getLegalActions(
|
|
state,
|
|
userId
|
|
) {
|
|
if (
|
|
!STREETS.includes(
|
|
state.state
|
|
)
|
|
) {
|
|
return {
|
|
actions: [],
|
|
reason:
|
|
"HAND_NOT_ACTIVE"
|
|
};
|
|
}
|
|
|
|
const player =
|
|
state.players.find(
|
|
candidate =>
|
|
candidate.userId ===
|
|
String(userId)
|
|
);
|
|
|
|
if (!player) {
|
|
return {
|
|
actions: [],
|
|
reason:
|
|
"PLAYER_NOT_FOUND"
|
|
};
|
|
}
|
|
|
|
if (
|
|
player.seat !==
|
|
state.currentSeat
|
|
) {
|
|
return {
|
|
actions: [],
|
|
reason:
|
|
"OUT_OF_TURN"
|
|
};
|
|
}
|
|
|
|
if (
|
|
!canPlayerAct(player)
|
|
) {
|
|
return {
|
|
actions: [],
|
|
reason:
|
|
"PLAYER_CANNOT_ACT"
|
|
};
|
|
}
|
|
|
|
const toCall =
|
|
Math.max(
|
|
0,
|
|
state.currentBet -
|
|
player.streetBet
|
|
);
|
|
|
|
const remaining =
|
|
remainingHandBudget(
|
|
player
|
|
);
|
|
|
|
const maxBetTo =
|
|
player.streetBet +
|
|
remaining;
|
|
|
|
const actions = [
|
|
"fold"
|
|
];
|
|
|
|
if (
|
|
toCall === 0
|
|
) {
|
|
actions.push(
|
|
"check"
|
|
);
|
|
}
|
|
|
|
else if (
|
|
remaining > 0
|
|
) {
|
|
actions.push(
|
|
"call"
|
|
);
|
|
}
|
|
|
|
if (
|
|
remaining > 0 &&
|
|
maxBetTo >
|
|
state.currentBet
|
|
) {
|
|
actions.push(
|
|
state.currentBet === 0
|
|
? "bet"
|
|
: "raise"
|
|
);
|
|
}
|
|
|
|
if (
|
|
remaining > 0
|
|
) {
|
|
actions.push(
|
|
"all_in"
|
|
);
|
|
}
|
|
|
|
const minimumRaiseTo =
|
|
state.currentBet === 0
|
|
? state.bigBlind
|
|
: state.currentBet +
|
|
state.lastRaiseSize;
|
|
|
|
return {
|
|
actions,
|
|
toCall,
|
|
minRaiseTo:
|
|
Math.min(
|
|
minimumRaiseTo,
|
|
maxBetTo
|
|
),
|
|
maxRaiseTo:
|
|
maxBetTo,
|
|
handCap:
|
|
state.handCap,
|
|
remainingHandBudget:
|
|
remaining
|
|
};
|
|
}
|
|
|
|
function applyAction(
|
|
originalState,
|
|
userId,
|
|
action
|
|
) {
|
|
const state =
|
|
cloneState(
|
|
originalState
|
|
);
|
|
|
|
if (
|
|
!action ||
|
|
!action.type
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_ACTION",
|
|
"An action type is required."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!STREETS.includes(
|
|
state.state
|
|
)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"HAND_NOT_ACTIVE",
|
|
"The hand is not accepting actions."
|
|
);
|
|
}
|
|
|
|
const player =
|
|
state.players.find(
|
|
candidate =>
|
|
candidate.userId ===
|
|
String(userId)
|
|
);
|
|
|
|
if (!player) {
|
|
throw new PokerEngineError(
|
|
"PLAYER_NOT_FOUND",
|
|
"Player is not part of this hand."
|
|
);
|
|
}
|
|
|
|
if (
|
|
player.seat !==
|
|
state.currentSeat
|
|
) {
|
|
throw new PokerEngineError(
|
|
"OUT_OF_TURN",
|
|
"It is not this player's turn."
|
|
);
|
|
}
|
|
|
|
if (
|
|
!canPlayerAct(player)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"PLAYER_CANNOT_ACT",
|
|
"This player cannot act."
|
|
);
|
|
}
|
|
|
|
const type =
|
|
String(
|
|
action.type
|
|
).toLowerCase();
|
|
|
|
const toCall =
|
|
Math.max(
|
|
0,
|
|
state.currentBet -
|
|
player.streetBet
|
|
);
|
|
|
|
if (type === "fold") {
|
|
player.folded = true;
|
|
player.actedThisStreet = true;
|
|
}
|
|
|
|
else if (
|
|
type === "check"
|
|
) {
|
|
if (
|
|
toCall !== 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"CANNOT_CHECK",
|
|
"Player cannot check while facing a bet."
|
|
);
|
|
}
|
|
|
|
player.actedThisStreet = true;
|
|
}
|
|
|
|
else if (
|
|
type === "call"
|
|
) {
|
|
if (
|
|
toCall <= 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"NOTHING_TO_CALL",
|
|
"There is no bet to call."
|
|
);
|
|
}
|
|
|
|
const remaining =
|
|
remainingHandBudget(
|
|
player
|
|
);
|
|
|
|
if (
|
|
remaining < toCall
|
|
) {
|
|
throw new PokerEngineError(
|
|
"CALL_EXCEEDS_CAP",
|
|
"Call would exceed the single-pot hand cap."
|
|
);
|
|
}
|
|
|
|
commitChips(
|
|
state,
|
|
player,
|
|
toCall
|
|
);
|
|
|
|
player.actedThisStreet = true;
|
|
|
|
if (
|
|
remainingHandBudget(
|
|
player
|
|
) === 0
|
|
) {
|
|
player.allIn = true;
|
|
}
|
|
}
|
|
|
|
else if (
|
|
type === "bet" ||
|
|
type === "raise"
|
|
) {
|
|
const target =
|
|
Number(
|
|
action.amount
|
|
);
|
|
|
|
if (
|
|
!Number.isSafeInteger(
|
|
target
|
|
) ||
|
|
target <= 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_RAISE",
|
|
"Bet or raise amount must be a positive integer target."
|
|
);
|
|
}
|
|
|
|
if (
|
|
type === "bet" &&
|
|
state.currentBet !== 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"BET_NOT_AVAILABLE",
|
|
"Use raise when a bet already exists."
|
|
);
|
|
}
|
|
|
|
if (
|
|
type === "raise" &&
|
|
state.currentBet === 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"RAISE_NOT_AVAILABLE",
|
|
"Use bet when no bet exists."
|
|
);
|
|
}
|
|
|
|
applyRaiseTo(
|
|
state,
|
|
player,
|
|
target
|
|
);
|
|
}
|
|
|
|
else if (
|
|
type === "all_in"
|
|
) {
|
|
const remaining =
|
|
remainingHandBudget(
|
|
player
|
|
);
|
|
|
|
if (
|
|
remaining <= 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"NO_CHIPS_AVAILABLE",
|
|
"Player has no playable chips remaining this hand."
|
|
);
|
|
}
|
|
|
|
const oldCurrentBet =
|
|
state.currentBet;
|
|
|
|
const target =
|
|
player.streetBet +
|
|
remaining;
|
|
|
|
commitChips(
|
|
state,
|
|
player,
|
|
remaining
|
|
);
|
|
|
|
player.allIn = true;
|
|
player.actedThisStreet = true;
|
|
|
|
if (
|
|
target >
|
|
oldCurrentBet
|
|
) {
|
|
const raiseSize =
|
|
target -
|
|
oldCurrentBet;
|
|
|
|
if (
|
|
raiseSize >=
|
|
state.lastRaiseSize
|
|
) {
|
|
state.lastRaiseSize =
|
|
raiseSize;
|
|
|
|
reopenBettingAfterRaise(
|
|
state,
|
|
player.seat
|
|
);
|
|
}
|
|
|
|
state.currentBet =
|
|
target;
|
|
}
|
|
}
|
|
|
|
else {
|
|
throw new PokerEngineError(
|
|
"UNKNOWN_ACTION",
|
|
"Unknown poker action."
|
|
);
|
|
}
|
|
|
|
progressAfterAction(
|
|
state,
|
|
player.seat
|
|
);
|
|
|
|
return state;
|
|
}
|
|
|
|
function applyRaiseTo(
|
|
state,
|
|
player,
|
|
target
|
|
) {
|
|
const remaining =
|
|
remainingHandBudget(
|
|
player
|
|
);
|
|
|
|
const maxTarget =
|
|
player.streetBet +
|
|
remaining;
|
|
|
|
if (
|
|
target > maxTarget
|
|
) {
|
|
throw new PokerEngineError(
|
|
"RAISE_EXCEEDS_CAP",
|
|
"Raise would exceed the single-pot hand cap."
|
|
);
|
|
}
|
|
|
|
if (
|
|
target <=
|
|
state.currentBet
|
|
) {
|
|
throw new PokerEngineError(
|
|
"RAISE_TOO_SMALL",
|
|
"Raise target must exceed the current bet."
|
|
);
|
|
}
|
|
|
|
const minimumTarget =
|
|
state.currentBet === 0
|
|
? state.bigBlind
|
|
: state.currentBet +
|
|
state.lastRaiseSize;
|
|
|
|
if (
|
|
target < minimumTarget
|
|
) {
|
|
throw new PokerEngineError(
|
|
"RAISE_TOO_SMALL",
|
|
`Minimum raise target is ${minimumTarget}.`
|
|
);
|
|
}
|
|
|
|
const oldCurrentBet =
|
|
state.currentBet;
|
|
|
|
const amount =
|
|
target -
|
|
player.streetBet;
|
|
|
|
commitChips(
|
|
state,
|
|
player,
|
|
amount
|
|
);
|
|
|
|
state.currentBet =
|
|
target;
|
|
|
|
state.lastRaiseSize =
|
|
target -
|
|
oldCurrentBet;
|
|
|
|
reopenBettingAfterRaise(
|
|
state,
|
|
player.seat
|
|
);
|
|
|
|
player.actedThisStreet = true;
|
|
|
|
if (
|
|
remainingHandBudget(
|
|
player
|
|
) === 0
|
|
) {
|
|
player.allIn = true;
|
|
}
|
|
}
|
|
|
|
function reopenBettingAfterRaise(
|
|
state,
|
|
raiserSeat
|
|
) {
|
|
for (const candidate of state.players) {
|
|
if (
|
|
candidate.seat !==
|
|
raiserSeat &&
|
|
canPlayerAct(candidate)
|
|
) {
|
|
candidate.actedThisStreet =
|
|
false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function commitChips(
|
|
state,
|
|
player,
|
|
amount
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(amount) ||
|
|
amount < 0
|
|
) {
|
|
throw new PokerEngineError(
|
|
"INVALID_COMMIT",
|
|
"Committed chips must be a non-negative integer."
|
|
);
|
|
}
|
|
|
|
if (
|
|
amount >
|
|
remainingHandBudget(player)
|
|
) {
|
|
throw new PokerEngineError(
|
|
"HAND_CAP_EXCEEDED",
|
|
"Player cannot contribute beyond the single-pot hand cap."
|
|
);
|
|
}
|
|
|
|
if (
|
|
amount > player.stack
|
|
) {
|
|
throw new PokerEngineError(
|
|
"STACK_EXCEEDED",
|
|
"Player does not have enough chips."
|
|
);
|
|
}
|
|
|
|
player.stack -= amount;
|
|
player.totalBet += amount;
|
|
player.streetBet += amount;
|
|
state.pot += amount;
|
|
}
|
|
|
|
function remainingHandBudget(
|
|
player
|
|
) {
|
|
return Math.max(
|
|
0,
|
|
player.handCap -
|
|
player.totalBet
|
|
);
|
|
}
|
|
|
|
function canPlayerAct(
|
|
player
|
|
) {
|
|
return (
|
|
!player.folded &&
|
|
!player.allIn &&
|
|
remainingHandBudget(
|
|
player
|
|
) > 0
|
|
);
|
|
}
|
|
|
|
function progressAfterAction(
|
|
state,
|
|
actedSeat
|
|
) {
|
|
const activePlayers =
|
|
state.players.filter(
|
|
player =>
|
|
!player.folded
|
|
);
|
|
|
|
if (
|
|
activePlayers.length === 1
|
|
) {
|
|
awardFoldWin(
|
|
state,
|
|
activePlayers[0]
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isBettingRoundComplete(
|
|
state
|
|
)
|
|
) {
|
|
advanceStreet(
|
|
state
|
|
);
|
|
|
|
progressUntilActionOrPayout(
|
|
state
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
state.currentSeat =
|
|
nextSeatAfter(
|
|
state.players,
|
|
actedSeat,
|
|
canPlayerAct
|
|
);
|
|
|
|
if (
|
|
state.currentSeat === null
|
|
) {
|
|
advanceStreet(
|
|
state
|
|
);
|
|
|
|
progressUntilActionOrPayout(
|
|
state
|
|
);
|
|
}
|
|
}
|
|
|
|
function progressUntilActionOrPayout(
|
|
state
|
|
) {
|
|
while (
|
|
STREETS.includes(
|
|
state.state
|
|
)
|
|
) {
|
|
const activePlayers =
|
|
state.players.filter(
|
|
player =>
|
|
!player.folded
|
|
);
|
|
|
|
if (
|
|
activePlayers.length === 1
|
|
) {
|
|
awardFoldWin(
|
|
state,
|
|
activePlayers[0]
|
|
);
|
|
return;
|
|
}
|
|
|
|
const actionable =
|
|
state.players.filter(
|
|
canPlayerAct
|
|
);
|
|
|
|
if (
|
|
actionable.length === 0
|
|
) {
|
|
advanceStreet(
|
|
state
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
state.currentSeat === null ||
|
|
!actionable.some(
|
|
player =>
|
|
player.seat ===
|
|
state.currentSeat
|
|
)
|
|
) {
|
|
state.currentSeat =
|
|
firstPostflopSeat(
|
|
state
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
function isBettingRoundComplete(
|
|
state
|
|
) {
|
|
const actionable =
|
|
state.players.filter(
|
|
canPlayerAct
|
|
);
|
|
|
|
if (
|
|
actionable.length === 0
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return actionable.every(
|
|
player =>
|
|
player.actedThisStreet &&
|
|
player.streetBet ===
|
|
state.currentBet
|
|
);
|
|
}
|
|
|
|
function advanceStreet(
|
|
state
|
|
) {
|
|
if (
|
|
state.state ===
|
|
"PREFLOP"
|
|
) {
|
|
state.state = "FLOP";
|
|
state.street = "FLOP";
|
|
|
|
state.communityCards.push(
|
|
state.deck.pop(),
|
|
state.deck.pop(),
|
|
state.deck.pop()
|
|
);
|
|
}
|
|
|
|
else if (
|
|
state.state ===
|
|
"FLOP"
|
|
) {
|
|
state.state = "TURN";
|
|
state.street = "TURN";
|
|
|
|
state.communityCards.push(
|
|
state.deck.pop()
|
|
);
|
|
}
|
|
|
|
else if (
|
|
state.state ===
|
|
"TURN"
|
|
) {
|
|
state.state = "RIVER";
|
|
state.street = "RIVER";
|
|
|
|
state.communityCards.push(
|
|
state.deck.pop()
|
|
);
|
|
}
|
|
|
|
else if (
|
|
state.state ===
|
|
"RIVER"
|
|
) {
|
|
state.state =
|
|
"SHOWDOWN";
|
|
state.street =
|
|
"SHOWDOWN";
|
|
|
|
resolveShowdown(
|
|
state
|
|
);
|
|
|
|
return state;
|
|
}
|
|
|
|
else {
|
|
return state;
|
|
}
|
|
|
|
state.currentBet = 0;
|
|
state.lastRaiseSize =
|
|
state.bigBlind;
|
|
|
|
for (const player of state.players) {
|
|
player.streetBet = 0;
|
|
player.actedThisStreet =
|
|
false;
|
|
}
|
|
|
|
state.currentSeat =
|
|
firstPostflopSeat(
|
|
state
|
|
);
|
|
|
|
return state;
|
|
}
|
|
|
|
function awardFoldWin(
|
|
state,
|
|
winner
|
|
) {
|
|
const payout =
|
|
state.pot;
|
|
|
|
winner.stack += payout;
|
|
|
|
state.result = {
|
|
type:
|
|
"fold",
|
|
winnerSeats: [
|
|
winner.seat
|
|
],
|
|
payouts: {
|
|
[winner.seat]:
|
|
payout
|
|
},
|
|
handRank: null
|
|
};
|
|
|
|
state.pot = 0;
|
|
state.state =
|
|
"PAYOUT";
|
|
state.currentSeat = null;
|
|
}
|
|
|
|
function firstPostflopSeat(
|
|
state
|
|
) {
|
|
return nextSeatAfter(
|
|
state.players,
|
|
state.dealerSeat,
|
|
canPlayerAct
|
|
);
|
|
}
|
|
|
|
function nextSeatAfter(
|
|
players,
|
|
seat,
|
|
predicate = () => true
|
|
) {
|
|
const seats =
|
|
players
|
|
.map(
|
|
player =>
|
|
player.seat
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
a - b
|
|
);
|
|
|
|
if (
|
|
seats.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const startIndex =
|
|
seats.findIndex(
|
|
candidate =>
|
|
candidate > seat
|
|
);
|
|
|
|
const ordered =
|
|
startIndex === -1
|
|
? seats.slice()
|
|
: [
|
|
...seats.slice(
|
|
startIndex
|
|
),
|
|
...seats.slice(
|
|
0,
|
|
startIndex
|
|
)
|
|
];
|
|
|
|
if (
|
|
startIndex === -1
|
|
) {
|
|
ordered.sort(
|
|
(a, b) =>
|
|
a - b
|
|
);
|
|
}
|
|
|
|
for (const candidateSeat of ordered) {
|
|
const player =
|
|
players.find(
|
|
candidate =>
|
|
candidate.seat ===
|
|
candidateSeat
|
|
);
|
|
|
|
if (
|
|
player &&
|
|
predicate(player)
|
|
) {
|
|
return candidateSeat;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function orderSeatsAfter(
|
|
seats,
|
|
seat
|
|
) {
|
|
const sorted =
|
|
[...new Set(seats)]
|
|
.sort(
|
|
(a, b) =>
|
|
a - b
|
|
);
|
|
|
|
if (
|
|
sorted.length <= 1
|
|
) {
|
|
return sorted;
|
|
}
|
|
|
|
const after =
|
|
sorted.filter(
|
|
candidate =>
|
|
candidate > seat
|
|
);
|
|
|
|
const before =
|
|
sorted.filter(
|
|
candidate =>
|
|
candidate <= seat
|
|
);
|
|
|
|
return [
|
|
...after,
|
|
...before
|
|
];
|
|
}
|
|
|
|
function getPlayerBySeat(
|
|
state,
|
|
seat
|
|
) {
|
|
const player =
|
|
state.players.find(
|
|
candidate =>
|
|
candidate.seat ===
|
|
seat
|
|
);
|
|
|
|
if (!player) {
|
|
throw new PokerEngineError(
|
|
"PLAYER_NOT_FOUND",
|
|
`No player occupies seat ${seat}.`
|
|
);
|
|
}
|
|
|
|
return player;
|
|
}
|
|
|
|
function getPublicState(
|
|
state
|
|
) {
|
|
return {
|
|
state:
|
|
state.state,
|
|
street:
|
|
state.street,
|
|
dealerSeat:
|
|
state.dealerSeat,
|
|
smallBlindSeat:
|
|
state.smallBlindSeat,
|
|
bigBlindSeat:
|
|
state.bigBlindSeat,
|
|
currentSeat:
|
|
state.currentSeat,
|
|
smallBlind:
|
|
state.smallBlind,
|
|
bigBlind:
|
|
state.bigBlind,
|
|
handCap:
|
|
state.handCap,
|
|
pot:
|
|
state.pot,
|
|
currentBet:
|
|
state.currentBet,
|
|
communityCards:
|
|
state.communityCards.map(
|
|
card => ({
|
|
...card
|
|
})
|
|
),
|
|
players:
|
|
state.players.map(
|
|
player => ({
|
|
userId:
|
|
player.userId,
|
|
seat:
|
|
player.seat,
|
|
stack:
|
|
player.stack,
|
|
folded:
|
|
player.folded,
|
|
allIn:
|
|
player.allIn,
|
|
streetBet:
|
|
player.streetBet,
|
|
totalBet:
|
|
player.totalBet
|
|
})
|
|
),
|
|
result:
|
|
state.result
|
|
};
|
|
}
|
|
|
|
function getPrivateCards(
|
|
state,
|
|
userId
|
|
) {
|
|
const player =
|
|
state.players.find(
|
|
candidate =>
|
|
candidate.userId ===
|
|
String(userId)
|
|
);
|
|
|
|
if (!player) {
|
|
throw new PokerEngineError(
|
|
"PLAYER_NOT_FOUND",
|
|
"Player is not part of this hand."
|
|
);
|
|
}
|
|
|
|
return player.holeCards.map(
|
|
card => ({
|
|
...card
|
|
})
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
SUITS,
|
|
RANKS,
|
|
RANK_VALUES,
|
|
HAND_CATEGORIES,
|
|
HAND_CATEGORY_NAMES,
|
|
STREETS,
|
|
|
|
PokerEngineError,
|
|
|
|
createDeck,
|
|
shuffleDeck,
|
|
cardToString,
|
|
|
|
evaluateFiveCardHand,
|
|
evaluateHand,
|
|
compareHandRanks,
|
|
splitPot,
|
|
resolveShowdown,
|
|
|
|
startHand,
|
|
getLegalActions,
|
|
applyAction,
|
|
advanceStreet,
|
|
|
|
getPublicState,
|
|
getPrivateCards,
|
|
|
|
remainingHandBudget,
|
|
canPlayerAct,
|
|
nextSeatAfter,
|
|
orderSeatsAfter
|
|
};
|