114 lines
4.2 KiB
JavaScript
114 lines
4.2 KiB
JavaScript
import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js';
|
||
|
||
// Per-seat personalities indexed by seat number (1–7 for AI seats)
|
||
const PERSONALITIES = {
|
||
1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 },
|
||
2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 },
|
||
3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 },
|
||
4: { style: 'aggressive', raiseFactor: 0.62, bluffRate: 0.30 },
|
||
5: { style: 'tight', raiseFactor: 0.22, bluffRate: 0.06 },
|
||
6: { style: 'loose', raiseFactor: 0.50, bluffRate: 0.18 },
|
||
7: { style: 'aggressive', raiseFactor: 0.48, bluffRate: 0.14 },
|
||
};
|
||
|
||
// Chen formula approximation: 0–20 score for pre-flop hand strength
|
||
function chenScore(hand) {
|
||
const [a, b] = [...hand].sort((x, y) => y.value - x.value);
|
||
const hi = a.value;
|
||
let score = hi >= 14 ? 10 : hi >= 13 ? 8 : hi >= 12 ? 7 : hi >= 11 ? 6 : hi / 2;
|
||
|
||
if (a.value === b.value) {
|
||
score = Math.max(score * 2, 5);
|
||
} else {
|
||
const gap = a.value - b.value - 1;
|
||
if (gap === 0) score += 1; // connected
|
||
else if (gap === 1) score -= 1; // 1-gap
|
||
else if (gap === 2) score -= 2; // 2-gap
|
||
else if (gap === 3) score -= 4; // 3-gap
|
||
else score -= 5; // bigger gap
|
||
|
||
if (b.value >= 2 && b.value <= 7 && gap <= 1) score += 1; // straight bonus
|
||
|
||
if (a.suit === b.suit) score += 2; // suited bonus
|
||
}
|
||
return Math.max(0, score);
|
||
}
|
||
|
||
// Post-flop: rough hand percentile (0–1) from evaluation rank + board texture
|
||
function handStrength(player, community) {
|
||
if (community.length === 0) return null;
|
||
const result = evaluateHand([...player.hand, ...community]);
|
||
// rank 0–8; normalize to 0–1 with exponential curve
|
||
return Math.min(1, (result.rank + 1) / 9 + result.tiebreakers[0] / 120);
|
||
}
|
||
|
||
// Pot odds: ratio of call amount to total pot after calling
|
||
function potOdds(state, seat) {
|
||
const toCall = callAmount(state, seat);
|
||
if (toCall === 0) return 1; // free to check
|
||
return toCall / (state.pot + toCall);
|
||
}
|
||
|
||
export function chooseAction(state, seat) {
|
||
const player = state.players[seat];
|
||
const pers = PERSONALITIES[seat] ?? PERSONALITIES[1];
|
||
const isCheck = canCheck(state, seat);
|
||
const toCall = callAmount(state, seat);
|
||
const odds = potOdds(state, seat);
|
||
|
||
// Determine strength metric
|
||
let strength;
|
||
if (state.phase === 'preflop') {
|
||
const chen = chenScore(player.hand);
|
||
strength = chen / 20; // normalize to 0–1
|
||
} else {
|
||
strength = handStrength(player, state.community) ?? 0.5;
|
||
}
|
||
|
||
// Bluffing: occasionally treat weak hand as strong
|
||
if (Math.random() < pers.bluffRate) strength = Math.min(1, strength + 0.40);
|
||
|
||
// Fold threshold: fold if strength is below the price of calling
|
||
let foldThreshold = odds * 0.88;
|
||
// When the pot hasn't been raised pre-flop it only costs the big blind to see
|
||
// the flop, so opponents limp in with almost anything instead of folding.
|
||
const unraisedPreflop = state.phase === 'preflop' && state.roundBet <= (state.blind?.big ?? state.roundBet);
|
||
if (unraisedPreflop) foldThreshold *= 0.25;
|
||
|
||
if (isCheck) {
|
||
// Check or bet
|
||
if (strength > 0.55 && Math.random() < pers.raiseFactor) {
|
||
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
|
||
return { type: 'raise', amount: betAmount };
|
||
}
|
||
return { type: 'check' };
|
||
}
|
||
|
||
// There's a bet to call
|
||
if (strength < foldThreshold && !isCheck) {
|
||
return { type: 'fold' };
|
||
}
|
||
|
||
// Strong enough to continue — raise or call?
|
||
if (strength > 0.62 && Math.random() < pers.raiseFactor) {
|
||
if (player.chips <= toCall * 1.5) {
|
||
return { type: 'allin' };
|
||
}
|
||
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
|
||
return { type: 'raise', amount: betAmount };
|
||
}
|
||
|
||
// Go all-in if call would leave almost nothing
|
||
if (toCall >= player.chips) return { type: 'allin' };
|
||
|
||
return { type: 'call' };
|
||
}
|
||
|
||
function computeRaiseAmount(state, seat, player, strength, pers) {
|
||
const base = minRaise(state);
|
||
// Scale raise size with hand strength and personality
|
||
const multiplier = 1 + strength * pers.raiseFactor * 3;
|
||
const raw = Math.round(base * multiplier / 5) * 5; // round to $5
|
||
return Math.min(raw, player.chips + (state.players[seat].bet));
|
||
}
|