fertig-classic-games/public/src/games/holdem/HoldemLogic.js

577 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 08, 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;
}