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

157 lines
3.9 KiB
Go

package plugin
import (
"fmt"
"sort"
"github.com/chehsunliu/poker"
"maunium.net/go/mautrix/id"
)
// handRank evaluates a player's best 5-card hand from 7 cards.
func handRank(hole [2]poker.Card, community []poker.Card) (int32, string) {
cards := make([]poker.Card, 0, 7)
cards = append(cards, hole[0], hole[1])
cards = append(cards, community...)
rank := poker.Evaluate(cards)
return rank, poker.RankString(rank)
}
type evaluatedPlayer struct {
seatIdx int
rank int32
name string
userID id.UserID
}
// runShowdown evaluates all hands and distributes pots. Returns showdown result lines and per-player winnings.
func runShowdown(g *HoldemGame) ([]showdownResult, map[id.UserID]int64) {
winnings := make(map[id.UserID]int64)
var results []showdownResult
// Evaluate all non-folded players.
var evaluated []evaluatedPlayer
for i, p := range g.Players {
if p.State == PlayerFolded || p.State == PlayerSatOut {
continue
}
rank, name := handRank(p.Hole, g.Community)
evaluated = append(evaluated, evaluatedPlayer{
seatIdx: i,
rank: rank,
name: name,
userID: p.UserID,
})
}
sort.Slice(evaluated, func(i, j int) bool {
return evaluated[i].rank < evaluated[j].rank // lower = better
})
if len(g.SidePots) > 0 {
// Distribute each side pot.
for _, sp := range g.SidePots {
distributePot(g, sp.Amount, sp.Eligible, evaluated, winnings, &results)
}
} else {
// Single pot — collect outstanding bets first.
g.collectPot()
eligible := make([]id.UserID, 0)
for _, e := range evaluated {
eligible = append(eligible, e.userID)
}
distributePot(g, g.Pot, eligible, evaluated, winnings, &results)
g.Pot = 0
}
// Add showdown lines for all players.
var showdownLines []showdownResult
for _, e := range evaluated {
p := g.Players[e.seatIdx]
won := winnings[p.UserID]
line := renderShowdownLine(p.DisplayName, p.Hole, e.name, won)
showdownLines = append(showdownLines, showdownResult{line: line})
}
return showdownLines, winnings
}
// distributePot distributes a pot among eligible winners.
func distributePot(g *HoldemGame, potAmount int64, eligible []id.UserID, evaluated []evaluatedPlayer, winnings map[id.UserID]int64, results *[]showdownResult) {
if potAmount == 0 {
return
}
eligibleSet := make(map[id.UserID]bool, len(eligible))
for _, uid := range eligible {
eligibleSet[uid] = true
}
// Find the best rank among eligible players.
var winners []evaluatedPlayer
bestRank := int32(7463)
for _, e := range evaluated {
if !eligibleSet[e.userID] {
continue
}
if e.rank < bestRank {
bestRank = e.rank
winners = []evaluatedPlayer{e}
} else if e.rank == bestRank {
winners = append(winners, e)
}
}
if len(winners) == 0 {
return
}
// Split pot.
share := potAmount / int64(len(winners))
remainder := potAmount % int64(len(winners))
for i, w := range winners {
won := share
if i == 0 {
won += remainder // leftmost seat gets the odd chip
}
g.Players[w.seatIdx].Stack += won
winnings[w.userID] += won
}
}
// awardPotToLastPlayer awards the entire pot to the only remaining player (all others folded).
func awardPotToLastPlayer(g *HoldemGame) (string, id.UserID) {
g.collectPot()
var winner *HoldemPlayer
for _, p := range g.Players {
if p.State != PlayerFolded && p.State != PlayerSatOut {
winner = p
break
}
}
if winner == nil {
return "", ""
}
winner.Stack += g.Pot
ann := fmt.Sprintf("🏆 **%s** wins €%d!", winner.DisplayName, g.Pot)
g.Pot = 0
return ann, winner.UserID
}
// settleNetDeltas credits each player's remaining stack back to their balance.
// Buy-in was debited at join time, so we credit the full final stack.
func settleNetDeltas(g *HoldemGame, euro *EuroPlugin) {
for _, p := range g.Players {
if p.IsNPC || p.State == PlayerSatOut {
continue
}
if p.Stack > 0 {
euro.Credit(p.UserID, float64(p.Stack), "holdem_cashout")
}
}
}