|
| 1 | +/** |
| 2 | + * Texas Hold'em LLM Prompts |
| 3 | + * |
| 4 | + * Game-specific prompts for LLM agents playing Texas Hold'em poker. |
| 5 | + */ |
| 6 | + |
| 7 | +import type { HoldemObservation, Card } from "@cmax/games"; |
| 8 | + |
| 9 | +export const TEXAS_HOLDEM_SYSTEM_PROMPT = `You are an expert poker player competing in a Texas Hold'em tournament. Make optimal decisions based on: |
| 10 | +- Hand strength and potential |
| 11 | +- Pot odds and implied odds |
| 12 | +- Position relative to dealer |
| 13 | +- Stack sizes and bet sizing |
| 14 | +- Opponent tendencies and bet patterns |
| 15 | +
|
| 16 | +HAND RANKINGS (strongest to weakest): |
| 17 | +1. Royal Flush (A-K-Q-J-10 same suit) |
| 18 | +2. Straight Flush (five consecutive cards, same suit) |
| 19 | +3. Four of a Kind (four cards of same rank) |
| 20 | +4. Full House (three of a kind + pair) |
| 21 | +5. Flush (five cards same suit) |
| 22 | +6. Straight (five consecutive cards) |
| 23 | +7. Three of a Kind |
| 24 | +8. Two Pair |
| 25 | +9. One Pair |
| 26 | +10. High Card |
| 27 | +
|
| 28 | +POSITION NOTES: |
| 29 | +- Early position: play tight, premium hands only |
| 30 | +- Middle position: slightly wider range |
| 31 | +- Late position: can play more hands, steal blinds |
| 32 | +- Button: best position, act last post-flop |
| 33 | +
|
| 34 | +BETTING STRATEGY: |
| 35 | +- Value bet with strong hands |
| 36 | +- Bluff selectively with good blockers |
| 37 | +- Consider pot odds when calling |
| 38 | +- Fold weak hands to heavy aggression |
| 39 | +
|
| 40 | +Respond with EXACTLY one of these actions (nothing else): |
| 41 | +- FOLD |
| 42 | +- CHECK |
| 43 | +- CALL |
| 44 | +- RAISE <amount> |
| 45 | +- ALL_IN`; |
| 46 | + |
| 47 | +const SUIT_SYMBOLS: Record<string, string> = { |
| 48 | + h: "\u2665", |
| 49 | + d: "\u2666", |
| 50 | + c: "\u2663", |
| 51 | + s: "\u2660", |
| 52 | +}; |
| 53 | + |
| 54 | +/** |
| 55 | + * Format a card for display |
| 56 | + */ |
| 57 | +function formatCard(card: Card): string { |
| 58 | + return `${card.rank}${SUIT_SYMBOLS[card.suit] || card.suit}`; |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Format cards array for display |
| 63 | + */ |
| 64 | +function formatCards(cards: Card[]): string { |
| 65 | + if (cards.length === 0) return "None yet"; |
| 66 | + return cards.map(formatCard).join(" "); |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * Format a Texas Hold'em observation into a prompt for the LLM |
| 71 | + */ |
| 72 | +export function formatPokerObservation(obs: HoldemObservation): string { |
| 73 | + const hand = obs.hand ? formatCards(obs.hand) : "Unknown"; |
| 74 | + const community = formatCards(obs.communityCards); |
| 75 | + |
| 76 | + const opponents = obs.opponents |
| 77 | + .map((o) => { |
| 78 | + let status = ""; |
| 79 | + if (o.folded) status = " (folded)"; |
| 80 | + else if (o.allIn) status = " (all-in)"; |
| 81 | + return ` - Player ${o.playerId}: ${o.chips} chips, bet ${o.bet}${status}`; |
| 82 | + }) |
| 83 | + .join("\n"); |
| 84 | + |
| 85 | + let actions = "FOLD"; |
| 86 | + if (obs.toCall === 0) { |
| 87 | + actions += ", CHECK"; |
| 88 | + } else { |
| 89 | + actions += `, CALL (${obs.toCall})`; |
| 90 | + } |
| 91 | + actions += `, RAISE (min: ${obs.minRaise})`; |
| 92 | + actions += ", ALL_IN"; |
| 93 | + |
| 94 | + return `CURRENT SITUATION: |
| 95 | +- Your hand: ${hand} |
| 96 | +- Community cards: ${community} |
| 97 | +- Pot: ${obs.pot} chips |
| 98 | +- Your chips: ${obs.myChips} |
| 99 | +- Your current bet: ${obs.myBet} |
| 100 | +- Amount to call: ${obs.toCall} |
| 101 | +- Minimum raise: ${obs.minRaise} |
| 102 | +- Betting round: ${obs.round.toUpperCase()} |
| 103 | +- Your position: ${getPositionDescription(obs)} |
| 104 | +
|
| 105 | +OPPONENTS: |
| 106 | +${opponents} |
| 107 | +
|
| 108 | +LEGAL ACTIONS: ${actions} |
| 109 | +
|
| 110 | +Choose your action:`; |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Get position description based on dealer button |
| 115 | + */ |
| 116 | +function getPositionDescription(obs: HoldemObservation): string { |
| 117 | + const numPlayers = obs.opponents.length + 1; |
| 118 | + const relativePosition = (obs.playerId - obs.dealerIndex + numPlayers) % numPlayers; |
| 119 | + |
| 120 | + if (numPlayers === 2) { |
| 121 | + return relativePosition === 0 ? "Button/Small Blind" : "Big Blind"; |
| 122 | + } |
| 123 | + |
| 124 | + switch (relativePosition) { |
| 125 | + case 0: |
| 126 | + return "Button (BTN)"; |
| 127 | + case 1: |
| 128 | + return "Small Blind (SB)"; |
| 129 | + case 2: |
| 130 | + return "Big Blind (BB)"; |
| 131 | + case 3: |
| 132 | + return "Under the Gun (UTG)"; |
| 133 | + default: |
| 134 | + if (relativePosition >= numPlayers - 2) { |
| 135 | + return "Late Position (CO/HJ)"; |
| 136 | + } |
| 137 | + return "Middle Position (MP)"; |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +/** |
| 142 | + * Parse LLM response to extract poker action |
| 143 | + */ |
| 144 | +export function parsePokerAction( |
| 145 | + response: string, |
| 146 | + obs: HoldemObservation |
| 147 | +): string { |
| 148 | + const normalized = response.trim().toUpperCase(); |
| 149 | + |
| 150 | + // Check for simple actions |
| 151 | + if (normalized === "FOLD") return "fold"; |
| 152 | + if (normalized === "CHECK") return "check"; |
| 153 | + if (normalized === "CALL") return "call"; |
| 154 | + if (normalized === "ALL_IN" || normalized === "ALLIN" || normalized === "ALL-IN") { |
| 155 | + return "all_in"; |
| 156 | + } |
| 157 | + |
| 158 | + // Parse RAISE <amount> |
| 159 | + const raiseMatch = normalized.match(/RAISE\s*(\d+)/); |
| 160 | + if (raiseMatch) { |
| 161 | + const amount = parseInt(raiseMatch[1], 10); |
| 162 | + const clampedAmount = Math.max(amount, obs.minRaise); |
| 163 | + return `raise_${clampedAmount}`; |
| 164 | + } |
| 165 | + |
| 166 | + // If just "RAISE" without amount, use minimum |
| 167 | + if (normalized.startsWith("RAISE")) { |
| 168 | + return `raise_${obs.minRaise}`; |
| 169 | + } |
| 170 | + |
| 171 | + // Default to fold if unparseable |
| 172 | + console.warn(`Could not parse poker action: "${response}", defaulting to fold`); |
| 173 | + return "fold"; |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * Estimate hand strength (simplified) |
| 178 | + */ |
| 179 | +export function estimateHandStrength( |
| 180 | + hand: [Card, Card], |
| 181 | + communityCards: Card[] |
| 182 | +): string { |
| 183 | + // Pre-flop evaluation |
| 184 | + if (communityCards.length === 0) { |
| 185 | + const [c1, c2] = hand; |
| 186 | + const isPair = c1.rank === c2.rank; |
| 187 | + const isSuited = c1.suit === c2.suit; |
| 188 | + |
| 189 | + const rankValue1 = "23456789TJQKA".indexOf(c1.rank); |
| 190 | + const rankValue2 = "23456789TJQKA".indexOf(c2.rank); |
| 191 | + const highRank = Math.max(rankValue1, rankValue2); |
| 192 | + const lowRank = Math.min(rankValue1, rankValue2); |
| 193 | + const gap = highRank - lowRank; |
| 194 | + |
| 195 | + // Premium pairs |
| 196 | + if (isPair && highRank >= 10) return "Premium (high pair)"; |
| 197 | + if (isPair && highRank >= 7) return "Strong (medium pair)"; |
| 198 | + if (isPair) return "Playable (low pair)"; |
| 199 | + |
| 200 | + // Big cards |
| 201 | + if (highRank >= 11 && lowRank >= 10) { |
| 202 | + return isSuited ? "Premium (big suited)" : "Strong (big cards)"; |
| 203 | + } |
| 204 | + |
| 205 | + // Suited connectors |
| 206 | + if (isSuited && gap <= 2 && lowRank >= 6) { |
| 207 | + return "Playable (suited connector)"; |
| 208 | + } |
| 209 | + |
| 210 | + // Suited aces |
| 211 | + if (isSuited && (c1.rank === "A" || c2.rank === "A")) { |
| 212 | + return "Playable (suited ace)"; |
| 213 | + } |
| 214 | + |
| 215 | + // High cards |
| 216 | + if (highRank >= 10 && lowRank >= 8) { |
| 217 | + return "Marginal (high cards)"; |
| 218 | + } |
| 219 | + |
| 220 | + return "Weak (fold candidate)"; |
| 221 | + } |
| 222 | + |
| 223 | + // Post-flop - simplified |
| 224 | + return "Evaluate based on board texture"; |
| 225 | +} |
0 commit comments