Files
gogobee/internal/plugin/holdem_tips.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

269 lines
7.5 KiB
Go

package plugin
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
var holdemTipsClient = &http.Client{Timeout: 15 * time.Second}
// loadTipsPref loads a user's tip preference from the database.
func loadTipsPref(userID id.UserID) bool {
d := db.Get()
var enabled int
err := d.QueryRow(`SELECT enabled FROM holdem_tips_prefs WHERE user_id = ?`, string(userID)).Scan(&enabled)
if err != nil {
return true // default: tips on
}
return enabled == 1
}
// saveTipsPref saves a user's tip preference.
func saveTipsPref(userID id.UserID, enabled bool) {
d := db.Get()
val := 0
if enabled {
val = 1
}
_, _ = d.Exec(
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
string(userID), val, val,
)
}
// holdemTipContext holds the data needed to generate a tip.
type holdemTipContext struct {
PlayerName string
Hole [2]string // rendered card strings
Community string // rendered board string
Equity EquityResult
SPR float64
PotOddsPct float64
ToCall int64
Pot int64
Stack int64
Street Street
Position string
NumActive int
}
// buildTipContext creates a tip context from the current game state.
func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
p := g.Players[playerIdx]
totalPot := g.Pot
for _, pp := range g.Players {
totalPot += pp.Bet
}
toCall := g.CurrentBet - p.Bet
if toCall < 0 {
toCall = 0
}
numActive := g.activeCount()
numOpp := numActive - 1
if numOpp < 1 {
numOpp = 1
}
iterations := 5000
if len(g.Community) > 0 {
iterations = 10000
}
eq := Equity(p.Hole, g.Community, numOpp, iterations)
spr := 0.0
if totalPot > 0 {
spr = float64(p.Stack) / float64(totalPot)
}
potOdds := 0.0
if toCall > 0 {
potOdds = float64(toCall) / float64(totalPot+toCall) * 100
}
community := "—"
if len(g.Community) > 0 {
community = renderCards(g.Community)
}
return holdemTipContext{
PlayerName: p.DisplayName,
Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
Community: community,
Equity: eq,
SPR: spr,
PotOddsPct: potOdds,
ToCall: toCall,
Pot: totalPot,
Stack: p.Stack,
Street: g.Street,
Position: g.positionLabel(playerIdx),
NumActive: numActive,
}
}
// generateTip generates a coaching tip, trying LLM first then falling back to rules.
func generateTip(ctx holdemTipContext) string {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
if host != "" && model != "" {
tip, err := generateLLMTip(host, model, ctx)
if err != nil {
slog.Debug("holdem: LLM tip failed, using fallback", "err", err)
} else if tip != "" {
return "💡 " + tip
}
}
return "💡 " + generateRulesTip(ctx)
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type chatChoice struct {
Message chatMessage `json:"message"`
}
type chatResponse struct {
Choices []chatChoice `json:"choices"`
}
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot.
You will be given structured game context including pre-computed equity.
Give exactly 2-4 sentences of actionable advice for the player's current decision.
Lead with the equity vs pot odds relationship when a bet is to call.
Be direct. No preamble. No praise. No "great hand" filler.`
var userPrompt strings.Builder
userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100))
if ctx.ToCall > 0 {
exceeds := "exceeds"
if ctx.Equity.Win*100 < ctx.PotOddsPct {
exceeds = "falls short of"
}
userPrompt.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
} else {
userPrompt.WriteString("Check available — no bet to call\n")
}
userPrompt.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Active players: %d\n",
ctx.SPR, ctx.Position, ctx.NumActive))
userPrompt.WriteString("\nWhat should I consider for my decision?")
req := chatRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt.String()},
},
Stream: false,
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
url := strings.TrimRight(host, "/") + "/v1/chat/completions"
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
var chatResp chatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
if len(chatResp.Choices) == 0 || chatResp.Choices[0].Message.Content == "" {
return "", fmt.Errorf("empty response")
}
tip := strings.TrimSpace(chatResp.Choices[0].Message.Content)
// Strip thinking tags if present.
if i := strings.Index(tip, "<think>"); i != -1 {
if j := strings.Index(tip, "</think>"); j != -1 {
tip = strings.TrimSpace(tip[:i] + tip[j+len("</think>"):])
}
}
return tip, nil
}
func generateRulesTip(ctx holdemTipContext) string {
equity := ctx.Equity.Win + ctx.Equity.Tie*0.5
var tip string
switch {
case equity > 0.80:
tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100)
case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct)
case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding unless you have a strong draw.", equity*100, ctx.PotOddsPct)
case ctx.ToCall == 0 && equity > 0.65:
tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100)
case ctx.ToCall == 0 && equity < 0.40:
tip = fmt.Sprintf("%.0f%% equity — check to control pot size. Not enough equity to bet.", equity*100)
case ctx.SPR < 1:
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
default:
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
}
// Position note.
switch ctx.Position {
case "BTN", "CO":
tip += " You have positional advantage — use it."
case "SB", "BB":
tip += " Out of position — play tighter."
case "UTG":
tip += " Early position — you need a strong range here."
}
if ctx.NumActive >= 4 {
tip += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value."
}
return tip
}