577 lines
18 KiB
JavaScript
577 lines
18 KiB
JavaScript
import { Deck } from '../cards/Deck.js';
|
||
|
||
// ── Blind schedule ────────────────────────────────────────────────────────────
|
||
export const BLIND_LEVELS = [
|
||
{ level: 1, small: 5, big: 10 },
|
||
{ level: 2, small: 10, big: 20 },
|
||
{ level: 3, small: 15, big: 30 },
|
||
{ level: 4, small: 25, big: 50 },
|
||
{ level: 5, small: 50, big: 100 },
|
||
{ level: 6, small: 75, big: 150 },
|
||
{ level: 7, small: 100, big: 200 },
|
||
{ level: 8, small: 150, big: 300 },
|
||
{ level: 9, small: 250, big: 500 },
|
||
{ level: 10, small: 400, big: 800 },
|
||
];
|
||
|
||
const LEVEL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||
|
||
export function getBlindLevel(elapsedMs) {
|
||
const idx = Math.min(
|
||
Math.floor(elapsedMs / LEVEL_INTERVAL_MS),
|
||
BLIND_LEVELS.length - 1,
|
||
);
|
||
return BLIND_LEVELS[idx];
|
||
}
|
||
|
||
// ── Hand evaluation ───────────────────────────────────────────────────────────
|
||
// Returns { rank: 0–8, name, tiebreakers: number[] }
|
||
// rank 0=high card … 8=straight flush
|
||
export function evaluateHand(cards) {
|
||
if (cards.length <= 5) return evaluate5(cards);
|
||
return bestFiveFrom(cards);
|
||
}
|
||
|
||
// Picks the best 5-card hand from any n≥6 card set via true C(n,5) enumeration.
|
||
function bestFiveFrom(cards) {
|
||
const n = cards.length;
|
||
let best = null;
|
||
for (let a = 0; a < n - 4; a++)
|
||
for (let b = a + 1; b < n - 3; b++)
|
||
for (let c = b + 1; c < n - 2; c++)
|
||
for (let d = c + 1; d < n - 1; d++)
|
||
for (let e = d + 1; e < n; e++) {
|
||
const result = evaluate5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||
if (!best || compareHands(result, best) > 0) best = result;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function evaluate5(cards) {
|
||
const values = cards.map((c) => c.value).sort((a, b) => b - a);
|
||
const suits = cards.map((c) => c.suit);
|
||
const ranks = cards.map((c) => c.rank);
|
||
|
||
const isFlush = suits.every((s) => s === suits[0]);
|
||
const isStraight = checkStraight(values);
|
||
const counts = countValues(values);
|
||
const groups = buildGroups(counts); // sorted by [count desc, value desc]
|
||
|
||
if (isFlush && isStraight) {
|
||
const high = isStraight === 'wheel' ? 5 : values[0];
|
||
return { rank: 8, name: high === 14 ? 'Royal Flush' : 'Straight Flush', tiebreakers: [high] };
|
||
}
|
||
if (groups[0][0] === 4) {
|
||
return { rank: 7, name: 'Four of a Kind', tiebreakers: [groups[0][1], groups[1][1]] };
|
||
}
|
||
if (groups[0][0] === 3 && groups[1][0] === 2) {
|
||
return { rank: 6, name: 'Full House', tiebreakers: [groups[0][1], groups[1][1]] };
|
||
}
|
||
if (isFlush) {
|
||
return { rank: 5, name: 'Flush', tiebreakers: values };
|
||
}
|
||
if (isStraight) {
|
||
const high = isStraight === 'wheel' ? 5 : values[0];
|
||
return { rank: 4, name: 'Straight', tiebreakers: [high] };
|
||
}
|
||
if (groups[0][0] === 3) {
|
||
return { rank: 3, name: 'Three of a Kind', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
|
||
}
|
||
if (groups[0][0] === 2 && groups[1][0] === 2) {
|
||
const [p1, p2, kick] = groups;
|
||
return { rank: 2, name: 'Two Pair', tiebreakers: [p1[1], p2[1], kick[1]] };
|
||
}
|
||
if (groups[0][0] === 2) {
|
||
return { rank: 1, name: 'Pair', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
|
||
}
|
||
return { rank: 0, name: 'High Card', tiebreakers: values };
|
||
}
|
||
|
||
function checkStraight(sortedValues) {
|
||
// Check wheel (A-2-3-4-5) by treating Ace as 1
|
||
const vals = [...new Set(sortedValues)];
|
||
if (vals.length < 5) return false;
|
||
|
||
// Standard straight
|
||
if (vals[0] - vals[4] === 4 && vals.length === 5) return true;
|
||
|
||
// Wheel: A-2-3-4-5 → sorted values [14,5,4,3,2]
|
||
if (vals[0] === 14 && vals[1] === 5 && vals[2] === 4 && vals[3] === 3 && vals[4] === 2) {
|
||
return 'wheel';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function countValues(values) {
|
||
const map = {};
|
||
for (const v of values) map[v] = (map[v] ?? 0) + 1;
|
||
return map;
|
||
}
|
||
|
||
function buildGroups(counts) {
|
||
return Object.entries(counts)
|
||
.map(([v, c]) => [c, Number(v)])
|
||
.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
|
||
}
|
||
|
||
// Returns positive if a beats b, negative if b beats a, 0 if tie
|
||
export function compareHands(a, b) {
|
||
if (a.rank !== b.rank) return a.rank - b.rank;
|
||
for (let i = 0; i < Math.max(a.tiebreakers.length, b.tiebreakers.length); i++) {
|
||
const diff = (a.tiebreakers[i] ?? 0) - (b.tiebreakers[i] ?? 0);
|
||
if (diff !== 0) return diff;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ── Pot building (side pots) ──────────────────────────────────────────────────
|
||
// Builds pots from p.totalBet (cumulative hand contribution), safe to call
|
||
// after bets have been swept into state.pot by collectBets.
|
||
// Returns array of { amount, eligibleSeats[] } ordered smallest to largest.
|
||
function buildPotsFromTotals(allPlayers) {
|
||
const live = allPlayers.filter((p) => !p.eliminated && p.totalBet > 0);
|
||
if (live.length === 0) return [];
|
||
|
||
const allInLevels = [...new Set(
|
||
live.filter((p) => p.allIn && !p.folded).map((p) => p.totalBet),
|
||
)].sort((a, b) => a - b);
|
||
|
||
const pots = [];
|
||
let prev = 0;
|
||
|
||
const slice = (cap) => {
|
||
let amount = 0;
|
||
const eligible = [];
|
||
for (const p of live) {
|
||
const contrib = Math.min(p.totalBet, cap) - Math.min(p.totalBet, prev);
|
||
if (contrib <= 0) continue;
|
||
amount += contrib;
|
||
if (!p.folded) eligible.push(p.seat);
|
||
}
|
||
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
|
||
prev = cap;
|
||
};
|
||
|
||
for (const level of allInLevels) slice(level);
|
||
|
||
// Main pot — uncapped remainder
|
||
let mainAmount = 0;
|
||
const mainEligible = [];
|
||
for (const p of live) {
|
||
const contrib = p.totalBet - prev;
|
||
if (contrib <= 0) continue;
|
||
mainAmount += contrib;
|
||
if (!p.folded) mainEligible.push(p.seat);
|
||
}
|
||
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
|
||
|
||
return pots;
|
||
}
|
||
|
||
// Legacy helper used for UI side-pot display during active betting rounds.
|
||
// Uses current-round p.bet values (only valid before collectBets is called).
|
||
export function buildPots(players) {
|
||
const active = players.filter((p) => !p.folded && p.bet > 0);
|
||
if (active.length === 0) return [];
|
||
const allInAmounts = [...new Set(
|
||
active.filter((p) => p.allIn).map((p) => p.bet),
|
||
)].sort((a, b) => a - b);
|
||
const pots = [];
|
||
let prev = 0;
|
||
const slice = (cap) => {
|
||
let amount = 0;
|
||
const eligible = [];
|
||
for (const p of players) {
|
||
if (p.folded) { amount += Math.min(p.bet, cap) - Math.min(p.bet, prev); }
|
||
else {
|
||
const contrib = Math.min(p.bet, cap) - Math.min(p.bet, prev);
|
||
if (contrib > 0) { amount += contrib; eligible.push(p.seat); }
|
||
}
|
||
}
|
||
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
|
||
prev = cap;
|
||
};
|
||
for (const level of allInAmounts) slice(level);
|
||
const mainEligible = players.filter((p) => !p.folded && p.bet > prev).map((p) => p.seat);
|
||
let mainAmount = 0;
|
||
for (const p of players) mainAmount += Math.max(0, p.bet - prev);
|
||
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
|
||
return pots;
|
||
}
|
||
|
||
// ── Showdown resolution ───────────────────────────────────────────────────────
|
||
// allPlayers: full players array including folded (needed for side-pot math).
|
||
// Returns Map<seat, chipsWon>
|
||
export function resolveShowdown(allPlayers, community) {
|
||
const pots = buildPotsFromTotals(allPlayers);
|
||
const winnings = new Map(allPlayers.map((p) => [p.seat, 0]));
|
||
|
||
for (const pot of pots) {
|
||
if (pot.eligibleSeats.length === 0) continue;
|
||
|
||
if (pot.eligibleSeats.length === 1) {
|
||
const seat = pot.eligibleSeats[0];
|
||
winnings.set(seat, (winnings.get(seat) ?? 0) + pot.amount);
|
||
continue;
|
||
}
|
||
|
||
const contenders = pot.eligibleSeats.map((seat) => {
|
||
const p = allPlayers.find((pl) => pl.seat === seat);
|
||
return { seat, eval: evaluateHand([...p.hand, ...community]) };
|
||
});
|
||
|
||
let best = contenders[0].eval;
|
||
for (const c of contenders) {
|
||
if (compareHands(c.eval, best) > 0) best = c.eval;
|
||
}
|
||
const winners = contenders.filter((c) => compareHands(c.eval, best) === 0);
|
||
|
||
const share = Math.floor(pot.amount / winners.length);
|
||
const remainder = pot.amount - share * winners.length;
|
||
const sorted = [...winners].sort((a, b) => a.seat - b.seat);
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
winnings.set(sorted[i].seat, (winnings.get(sorted[i].seat) ?? 0) + share + (i === 0 ? remainder : 0));
|
||
}
|
||
}
|
||
|
||
return winnings;
|
||
}
|
||
|
||
// ── Initial state ─────────────────────────────────────────────────────────────
|
||
export function createInitialState(opponentDefs, buyIn) {
|
||
const players = [
|
||
{
|
||
seat: 0,
|
||
name: 'You',
|
||
chips: buyIn,
|
||
hand: [],
|
||
folded: false,
|
||
allIn: false,
|
||
bet: 0,
|
||
totalBet: 0,
|
||
isHuman: true,
|
||
isDealer: false,
|
||
eliminated: false,
|
||
hasActedThisRound: false,
|
||
},
|
||
...opponentDefs.slice(0, 3).map((opp, i) => ({
|
||
seat: i + 1,
|
||
name: opp.name,
|
||
chips: buyIn,
|
||
hand: [],
|
||
folded: false,
|
||
allIn: false,
|
||
bet: 0,
|
||
totalBet: 0,
|
||
isHuman: false,
|
||
isDealer: false,
|
||
eliminated: false,
|
||
hasActedThisRound: false,
|
||
})),
|
||
];
|
||
|
||
return {
|
||
phase: 'waiting',
|
||
deck: null,
|
||
players,
|
||
community: [],
|
||
pot: 0,
|
||
sidePots: [],
|
||
dealerSeat: 0,
|
||
actionSeat: -1,
|
||
roundBet: 0,
|
||
lastRaiser: null,
|
||
gameStartMs: Date.now(),
|
||
handNumber: 0,
|
||
};
|
||
}
|
||
|
||
// ── Hand setup ────────────────────────────────────────────────────────────────
|
||
export function startHand(state) {
|
||
const activePlayers = state.players.filter((p) => !p.eliminated);
|
||
if (activePlayers.length < 2) return state; // game over
|
||
|
||
// Advance dealer
|
||
let dealerSeat = state.dealerSeat;
|
||
for (let i = 0; i < state.players.length; i++) {
|
||
dealerSeat = (dealerSeat + 1) % state.players.length;
|
||
if (!state.players[dealerSeat].eliminated) break;
|
||
}
|
||
|
||
// Reset all hands
|
||
const players = state.players.map((p) => ({
|
||
...p,
|
||
hand: [],
|
||
folded: p.eliminated,
|
||
allIn: false,
|
||
bet: 0,
|
||
totalBet: 0,
|
||
isDealer: p.seat === dealerSeat,
|
||
hasActedThisRound: false,
|
||
}));
|
||
|
||
// Deal 2 cards each
|
||
const deck = new Deck();
|
||
deck.shuffle();
|
||
for (let i = 0; i < 2; i++) {
|
||
for (const p of players) {
|
||
if (!p.eliminated) p.hand.push(deck.deal(1)[0]);
|
||
}
|
||
}
|
||
|
||
// Post blinds
|
||
const blind = getBlindLevel(Date.now() - state.gameStartMs);
|
||
const { small: sb, big: bb } = blind;
|
||
const seatOrder = getSeatOrder(players, dealerSeat);
|
||
const sbSeat = seatOrder[0];
|
||
const bbSeat = seatOrder[1];
|
||
|
||
for (const p of players) {
|
||
if (p.seat === sbSeat) {
|
||
const amount = Math.min(p.chips, sb);
|
||
p.chips -= amount;
|
||
p.bet = amount;
|
||
p.totalBet = amount;
|
||
if (p.chips === 0) p.allIn = true;
|
||
}
|
||
if (p.seat === bbSeat) {
|
||
const amount = Math.min(p.chips, bb);
|
||
p.chips -= amount;
|
||
p.bet = amount;
|
||
p.totalBet = amount;
|
||
if (p.chips === 0) p.allIn = true;
|
||
}
|
||
}
|
||
|
||
// First to act pre-flop is after big blind
|
||
let actionSeat = seatOrder[2] ?? seatOrder[0];
|
||
|
||
return {
|
||
...state,
|
||
phase: 'preflop',
|
||
deck,
|
||
players,
|
||
community: [],
|
||
pot: 0,
|
||
sidePots: [],
|
||
dealerSeat,
|
||
actionSeat,
|
||
roundBet: bb,
|
||
lastRaiser: bbSeat,
|
||
handNumber: state.handNumber + 1,
|
||
sbSeat,
|
||
bbSeat,
|
||
blind,
|
||
};
|
||
}
|
||
|
||
// Returns active (not eliminated, not folded) seat numbers starting after dealer
|
||
function getSeatOrder(players, dealerSeat) {
|
||
const n = players.length;
|
||
const order = [];
|
||
for (let i = 1; i <= n; i++) {
|
||
const seat = (dealerSeat + i) % n;
|
||
if (!players[seat].eliminated) order.push(seat);
|
||
}
|
||
return order;
|
||
}
|
||
|
||
// ── Betting actions ───────────────────────────────────────────────────────────
|
||
export function applyAction(state, seat, action) {
|
||
if (state.actionSeat !== seat) return state;
|
||
|
||
const players = state.players.map((p) => ({ ...p }));
|
||
const player = players[seat];
|
||
let { pot, roundBet, lastRaiser } = state;
|
||
|
||
switch (action.type) {
|
||
case 'fold':
|
||
player.folded = true;
|
||
player.hasActedThisRound = true;
|
||
break;
|
||
|
||
case 'check':
|
||
if (player.bet < roundBet) return state;
|
||
player.hasActedThisRound = true;
|
||
break;
|
||
|
||
case 'call': {
|
||
const toCall = Math.min(roundBet - player.bet, player.chips);
|
||
player.chips -= toCall;
|
||
player.bet += toCall;
|
||
player.totalBet += toCall;
|
||
if (player.chips === 0) player.allIn = true;
|
||
player.hasActedThisRound = true;
|
||
break;
|
||
}
|
||
|
||
case 'raise': {
|
||
const minR = roundBet === 0 ? (state.blind?.big ?? 10) : roundBet * 2;
|
||
const raiseTotal = Math.max(minR, action.amount ?? minR);
|
||
const toAdd = Math.min(raiseTotal - player.bet, player.chips);
|
||
player.chips -= toAdd;
|
||
player.bet += toAdd;
|
||
player.totalBet += toAdd;
|
||
if (player.chips === 0) player.allIn = true;
|
||
roundBet = player.bet;
|
||
lastRaiser = seat;
|
||
for (const p of players) {
|
||
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
|
||
p.hasActedThisRound = false;
|
||
}
|
||
}
|
||
player.hasActedThisRound = true;
|
||
break;
|
||
}
|
||
|
||
case 'allin': {
|
||
const toAdd = player.chips;
|
||
player.chips = 0;
|
||
player.bet += toAdd;
|
||
player.totalBet += toAdd;
|
||
player.allIn = true;
|
||
if (player.bet > roundBet) {
|
||
roundBet = player.bet;
|
||
lastRaiser = seat;
|
||
for (const p of players) {
|
||
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
|
||
p.hasActedThisRound = false;
|
||
}
|
||
}
|
||
}
|
||
player.hasActedThisRound = true;
|
||
break;
|
||
}
|
||
|
||
default:
|
||
return state;
|
||
}
|
||
|
||
const nextSeat = nextToAct(players, seat, roundBet);
|
||
|
||
if (nextSeat === null) {
|
||
// Betting round over — collect bets into pot and advance phase
|
||
return advancePhase({ ...state, players, pot, roundBet, lastRaiser });
|
||
}
|
||
|
||
return { ...state, players, pot, roundBet, lastRaiser, actionSeat: nextSeat };
|
||
}
|
||
|
||
function nextToAct(players, currentSeat, roundBet) {
|
||
const n = players.length;
|
||
for (let i = 1; i <= n; i++) {
|
||
const seat = (currentSeat + i) % n;
|
||
const p = players[seat];
|
||
if (p.eliminated || p.folded || p.allIn) continue;
|
||
if (p.bet < roundBet || !p.hasActedThisRound) return seat;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function collectBets(players, pot) {
|
||
let total = pot;
|
||
const updated = players.map((p) => {
|
||
total += p.bet;
|
||
return { ...p, bet: 0, hasActedThisRound: false };
|
||
});
|
||
return { players: updated, pot: total };
|
||
}
|
||
|
||
function advancePhase(state) {
|
||
const { players, deck, community } = state;
|
||
|
||
// Check if only one player remains (everyone else folded)
|
||
const remaining = players.filter((p) => !p.eliminated && !p.folded);
|
||
const { players: clearedPlayers, pot } = collectBets(players, state.pot);
|
||
|
||
if (remaining.length === 1) {
|
||
// Award pot to last player standing
|
||
const winner = clearedPlayers.find((p) => p.seat === remaining[0].seat);
|
||
winner.chips += pot;
|
||
return endHand({ ...state, players: clearedPlayers, pot: 0, phase: 'showdown', community });
|
||
}
|
||
|
||
const nextPhase = { preflop: 'flop', flop: 'turn', turn: 'river', river: 'showdown' }[state.phase];
|
||
|
||
let newCommunity = [...community];
|
||
if (nextPhase === 'flop') newCommunity = [...newCommunity, ...deck.deal(3)];
|
||
if (nextPhase === 'turn') newCommunity = [...newCommunity, ...deck.deal(1)];
|
||
if (nextPhase === 'river') newCommunity = [...newCommunity, ...deck.deal(1)];
|
||
|
||
if (nextPhase === 'showdown') {
|
||
return endHand({ ...state, players: clearedPlayers, pot, community: newCommunity, phase: 'showdown' });
|
||
}
|
||
|
||
// Reset bets for new round; first to act is first active after dealer
|
||
const seatOrder = getSeatOrder(clearedPlayers, state.dealerSeat);
|
||
const actionSeat = seatOrder[0];
|
||
|
||
return {
|
||
...state,
|
||
phase: nextPhase,
|
||
players: clearedPlayers,
|
||
pot,
|
||
community: newCommunity,
|
||
roundBet: 0,
|
||
lastRaiser: null,
|
||
actionSeat,
|
||
sidePots: buildPots(clearedPlayers),
|
||
};
|
||
}
|
||
|
||
function endHand(state) {
|
||
const { players, community } = state;
|
||
let updatedPlayers = players.map((p) => ({ ...p }));
|
||
|
||
if (state.phase === 'showdown' && state.pot > 0) {
|
||
const active = updatedPlayers.filter((p) => !p.eliminated && !p.folded);
|
||
if (active.length === 1) {
|
||
// Everyone else folded — sole survivor takes the pot
|
||
updatedPlayers[active[0].seat].chips += state.pot;
|
||
} else if (active.length > 1) {
|
||
// Full showdown — use totalBet to build side pots and evaluate hands
|
||
const winnings = resolveShowdown(updatedPlayers, community);
|
||
for (const [seat, amount] of winnings) {
|
||
updatedPlayers[seat].chips += amount;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Eliminate players at 0 chips
|
||
for (const p of updatedPlayers) {
|
||
if (p.chips === 0 && !p.eliminated) p.eliminated = true;
|
||
}
|
||
|
||
const stillIn = updatedPlayers.filter((p) => !p.eliminated);
|
||
const gameOver = stillIn.length <= 1;
|
||
|
||
return {
|
||
...state,
|
||
players: updatedPlayers,
|
||
pot: 0,
|
||
sidePots: [],
|
||
phase: gameOver ? 'game_over' : 'between_hands',
|
||
winner: gameOver ? stillIn[0]?.seat ?? null : null,
|
||
};
|
||
}
|
||
|
||
// ── Query helpers ─────────────────────────────────────────────────────────────
|
||
export function getActivePlayers(state) {
|
||
return state.players.filter((p) => !p.eliminated && !p.folded);
|
||
}
|
||
|
||
export function canCheck(state, seat) {
|
||
const p = state.players[seat];
|
||
return p && !p.folded && p.bet >= state.roundBet;
|
||
}
|
||
|
||
export function callAmount(state, seat) {
|
||
const p = state.players[seat];
|
||
if (!p) return 0;
|
||
return Math.min(state.roundBet - p.bet, p.chips);
|
||
}
|
||
|
||
export function minRaise(state) {
|
||
return state.roundBet === 0 ? (state.blind?.big ?? 10) : state.roundBet * 2;
|
||
}
|