Files
gogobee/internal/plugin/holdem_render.go
prosolis c7c1b76589 Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment
shop, treasure system, TwinBee NPC, streak/grudge/party mechanics,
flavor text, and scheduled morning/evening/midnight tickers.

Holdem CFR: Fix three critical training bugs (fold not forfeiting pot,
free calls after raises, training/runtime key mismatch). Add performance
optimizations (preflop lookup table, zero-alloc equity, integer keys,
raise cap, regret pruning). Enrich abstraction with 12 equity buckets,
board texture dimension, and 6-char action history. Replace validation
with proper multi-street simulation.

Also includes wordle plugin, holdem seed tooling, and schema additions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:09:57 -07:00

322 lines
9.1 KiB
Go

package plugin
import (
"fmt"
"strings"
"github.com/chehsunliu/poker"
)
// cardGlyphMap maps poker card strings (e.g. "As") to Unicode glyphs.
var cardGlyphMap map[string]string
func init() {
cardGlyphMap = make(map[string]string, 52)
// Unicode playing card block. Q and K skip a codepoint (Knight sits between J and Q).
// Spades: U+1F0A1..
// Hearts: U+1F0B1..
// Diamonds: U+1F0C1..
// Clubs: U+1F0D1..
type suitInfo struct {
letter string
base rune
}
suits := []suitInfo{
{"s", 0x1F0A0}, // Spades
{"h", 0x1F0B0}, // Hearts
{"d", 0x1F0C0}, // Diamonds
{"c", 0x1F0D0}, // Clubs
}
// Rank offsets within each suit block.
// A=1, 2=2, ..., 10=10, J=11, Q=13(skip Knight at 12), K=14
rankOffsets := map[string]int{
"A": 1, "2": 2, "3": 3, "4": 4, "5": 5,
"6": 6, "7": 7, "8": 8, "9": 9, "T": 10,
"J": 11, "Q": 13, "K": 14,
}
for _, s := range suits {
for rank, offset := range rankOffsets {
cardStr := rank + s.letter
glyph := string(rune(s.base + rune(offset)))
cardGlyphMap[cardStr] = glyph
}
}
}
// holdemSuitSymbols maps poker library suit letters to display symbols.
var holdemSuitSymbols = map[byte]string{
's': "♠", 'h': "♥", 'd': "♦", 'c': "♣",
}
// rankDisplay converts library rank chars to display. "T" -> "10", rest unchanged.
var rankDisplay = map[byte]string{
'2': "2", '3': "3", '4': "4", '5': "5", '6': "6",
'7': "7", '8': "8", '9': "9", 'T': "10",
'J': "J", 'Q': "Q", 'K': "K", 'A': "A",
}
// renderCard renders a card as "🂡 (A♠)".
func renderCard(c poker.Card) string {
s := c.String() // e.g. "As", "Td"
glyph := cardGlyphMap[s]
if glyph == "" {
glyph = "🂠"
}
rank := rankDisplay[s[0]]
suit := holdemSuitSymbols[s[1]]
return fmt.Sprintf("%s (%s%s)", glyph, rank, suit)
}
// renderCards renders multiple cards separated by double space.
func renderCards(cards []poker.Card) string {
parts := make([]string, len(cards))
for i, c := range cards {
parts[i] = renderCard(c)
}
return strings.Join(parts, " ")
}
// renderHoleCards renders a 2-card hole hand.
func renderHoleCards(hole [2]poker.Card) string {
return renderCard(hole[0]) + " " + renderCard(hole[1])
}
// renderTableView builds the DM table view for a specific player.
func renderTableView(g *HoldemGame, viewerIdx int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎰 **Texas Hold'em** | %s\n", g.Street.String()))
// Pot info.
if len(g.SidePots) > 0 {
total := int64(0)
for _, sp := range g.SidePots {
total += sp.Amount
}
sb.WriteString(fmt.Sprintf("Pot: €%d", total))
for i, sp := range g.SidePots {
sb.WriteString(fmt.Sprintf(" | Side %d: €%d", i+1, sp.Amount))
}
sb.WriteString("\n")
} else {
// Include outstanding bets in displayed pot.
totalPot := g.Pot
for _, p := range g.Players {
totalPot += p.Bet
}
sb.WriteString(fmt.Sprintf("Pot: €%d\n", totalPot))
}
// Board.
if len(g.Community) > 0 {
sb.WriteString(fmt.Sprintf("Board: %s\n", renderCards(g.Community)))
} else {
sb.WriteString("Board: —\n")
}
sb.WriteString("\n**Seats:**\n")
for i, p := range g.Players {
if p.State == PlayerSatOut {
sb.WriteString(fmt.Sprintf(" %s — *(sitting out)*\n", p.DisplayName))
continue
}
marker := ""
if i == g.DealerIdx {
marker = " 🔘"
}
actionMarker := ""
if i == g.ActionIdx && g.HandInProgress && g.Street != StreetShowdown {
actionMarker = " ← action"
}
switch p.State {
case PlayerFolded:
sb.WriteString(fmt.Sprintf(" %s%s — €%d [folded]%s\n", p.DisplayName, marker, p.Stack, actionMarker))
case PlayerAllIn:
sb.WriteString(fmt.Sprintf(" %s%s — €%d [ALL IN]%s\n", p.DisplayName, marker, p.Stack, actionMarker))
default:
betStr := ""
if p.Bet > 0 {
betStr = fmt.Sprintf(" | bet: €%d", p.Bet)
}
sb.WriteString(fmt.Sprintf(" %s%s — €%d%s%s\n", p.DisplayName, marker, p.Stack, betStr, actionMarker))
}
}
// If viewer is the action player, show action prompt.
if viewerIdx == g.ActionIdx && g.HandInProgress && g.Street != StreetShowdown {
p := g.Players[viewerIdx]
if p.State == PlayerActive {
sb.WriteString("\n")
toCall := g.CurrentBet - p.Bet
if toCall > p.Stack {
toCall = p.Stack
}
if toCall > 0 {
minRaiseTo := g.CurrentBet + g.MinRaise
sb.WriteString(fmt.Sprintf("To call: €%d | Min raise to: €%d\n", toCall, minRaiseTo))
sb.WriteString("`!holdem call` `!holdem raise <amount>` `!holdem allin` `!holdem fold`\n")
} else {
minRaiseTo := g.CurrentBet + g.MinRaise
if g.CurrentBet == 0 {
minRaiseTo = g.BigBlind
}
sb.WriteString(fmt.Sprintf("Check available | Min bet: €%d\n", minRaiseTo))
sb.WriteString("`!holdem check` `!holdem raise <amount>` `!holdem allin` `!holdem fold`\n")
}
}
}
return sb.String()
}
// renderPrivateHand builds the private hand DM for a player.
func renderPrivateHand(g *HoldemGame, playerIdx int) string {
p := g.Players[playerIdx]
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🃏 Your hand: %s\n", renderHoleCards(p.Hole)))
if len(g.Community) > 0 {
sb.WriteString(fmt.Sprintf("Board: %s\n", renderCards(g.Community)))
}
totalPot := g.Pot
for _, pp := range g.Players {
totalPot += pp.Bet
}
toCall := g.CurrentBet - p.Bet
if toCall < 0 {
toCall = 0
}
if toCall > p.Stack {
toCall = p.Stack
}
sb.WriteString(fmt.Sprintf("Stack: €%d | Pot: €%d | To call: €%d\n", p.Stack, totalPot, toCall))
return sb.String()
}
// renderActionAnnouncement formats an action for room/DM announcement.
func renderActionAnnouncement(name, action string, amount int64) string {
switch action {
case "fold":
return fmt.Sprintf("**%s** folds.", name)
case "check":
return fmt.Sprintf("**%s** checks.", name)
case "call":
return fmt.Sprintf("**%s** calls €%d.", name, amount)
case "raise":
return fmt.Sprintf("**%s** raises to €%d.", name, amount)
case "allin":
return fmt.Sprintf("**%s** is ALL IN for €%d! 🚨", name, amount)
default:
return fmt.Sprintf("**%s** acts.", name)
}
}
// renderWinnerAnnouncement formats a winner message.
func renderWinnerAnnouncement(name string, amount int64, handName string, showCards bool, hole [2]poker.Card) string {
if showCards {
return fmt.Sprintf("🏆 **%s** wins €%d with %s! %s", name, amount, handName, renderHoleCards(hole))
}
return fmt.Sprintf("🏆 **%s** wins €%d!", name, amount)
}
// renderShowdownLine formats one player's showdown result.
func renderShowdownLine(name string, hole [2]poker.Card, handName string, won int64) string {
if won > 0 {
return fmt.Sprintf(" %s: %s — %s (won €%d)", name, renderHoleCards(hole), handName, won)
}
return fmt.Sprintf(" %s: %s — %s", name, renderHoleCards(hole), handName)
}
// renderUncalledBetReturn formats the uncalled bet message.
func renderUncalledBetReturn(name string, amount int64) string {
return fmt.Sprintf("↩️ Uncalled bet of €%d returned to **%s**.", amount, name)
}
// renderStartAnnouncement formats the hand start message for the room.
func renderStartAnnouncement(g *HoldemGame) string {
var sb strings.Builder
sb.WriteString("🎰 **Texas Hold'em** — Hand starting!\n\n")
sb.WriteString("**Players:**\n")
for i, p := range g.Players {
if p.State == PlayerSatOut {
continue
}
pos := g.positionLabel(i)
sb.WriteString(fmt.Sprintf(" %s (%s) — €%d\n", p.DisplayName, pos, p.Stack))
}
sb.WriteString(fmt.Sprintf("\nBlinds: €%d / €%d\n", g.SmallBlind, g.BigBlind))
return sb.String()
}
// renderEndAnnouncement formats the hand end message for the room.
func renderEndAnnouncement(results []showdownResult, g *HoldemGame) string {
var sb strings.Builder
sb.WriteString("🎰 **Texas Hold'em** — Hand complete!\n\n")
if len(g.Community) > 0 {
sb.WriteString(fmt.Sprintf("Board: %s\n\n", renderCards(g.Community)))
}
if len(results) > 0 {
sb.WriteString("**Results:**\n")
for _, r := range results {
sb.WriteString(r.line + "\n")
}
}
sb.WriteString("\n**Stacks:**\n")
for _, p := range g.Players {
if p.State == PlayerSatOut {
continue
}
delta := p.Stack - p.OpeningStack
sign := ""
if delta > 0 {
sign = "+"
}
sb.WriteString(fmt.Sprintf(" %s — €%d (%s%d)\n", p.DisplayName, p.Stack, sign, delta))
}
return sb.String()
}
// renderHelpMessage returns the help text for !holdem help.
func renderHelpMessage() string {
return "🎰 **Texas Hold'em Commands**\n\n" +
"**Lobby:**\n" +
"`!holdem join` — Sit down at the table\n" +
"`!holdem leave` — Leave the table\n" +
"`!holdem start` — Start dealing (≥2 players)\n" +
"`!holdem addbot` — Add an AI opponent\n\n" +
"**In-Game (your turn):**\n" +
"`!holdem fold` — Fold your hand\n" +
"`!holdem check` — Check (no bet to call)\n" +
"`!holdem call` — Call the current bet\n" +
"`!holdem raise <amount>` — Raise to a total of €amount\n" +
"`!holdem allin` — Go all-in\n\n" +
"**Other:**\n" +
"`!holdem status` — Get current table state (DM)\n" +
"`!holdem help` — Show this message\n\n" +
"**DM-Only:**\n" +
"`!holdem tips on/off` — Toggle coaching tips\n"
}
// showdownResult holds one player's showdown display line.
type showdownResult struct {
line string
}