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>
This commit is contained in:
prosolis
2026-03-22 10:09:57 -07:00
parent e9890fd880
commit c7c1b76589
35 changed files with 12388 additions and 3 deletions

View File

@@ -0,0 +1,376 @@
package plugin
import (
"fmt"
"sort"
"maunium.net/go/mautrix/id"
)
// postBlinds posts SB and BB, handling heads-up edge case.
func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) {
inHand := g.inHandPlayers()
n := len(inHand)
if n == 2 {
// Heads-up: dealer posts SB, other posts BB.
sbIdx = g.DealerIdx
bbIdx = g.nextActiveIdx(g.DealerIdx)
} else {
sbIdx = g.nextActiveIdx(g.DealerIdx)
bbIdx = g.nextActiveIdx(sbIdx)
}
// Post small blind.
sb := g.Players[sbIdx]
sbAmount := g.SmallBlind
if sbAmount > sb.Stack {
sbAmount = sb.Stack
}
sb.Stack -= sbAmount
sb.Bet = sbAmount
sb.TotalBet = sbAmount
if sb.Stack == 0 {
sb.State = PlayerAllIn
}
// Post big blind.
bb := g.Players[bbIdx]
bbAmount := g.BigBlind
if bbAmount > bb.Stack {
bbAmount = bb.Stack
}
bb.Stack -= bbAmount
bb.Bet = bbAmount
bb.TotalBet = bbAmount
if bb.Stack == 0 {
bb.State = PlayerAllIn
}
g.CurrentBet = g.BigBlind
g.MinRaise = g.BigBlind
g.LastAggressorIdx = bbIdx // BB has option
return sbIdx, bbIdx
}
// firstToActPreflop returns the seat index of the first player to act preflop.
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
n := len(g.Players)
if n == 2 {
// Heads-up: dealer/SB acts first preflop.
return g.DealerIdx
}
// UTG = next active after BB.
return g.nextCanActIdx(bbIdx)
}
// firstToActPostflop returns the first player to act on post-flop streets.
func (g *HoldemGame) firstToActPostflop() int {
// First active player after dealer.
return g.nextCanActIdx(g.DealerIdx)
}
// ActionResult describes what happened after an action.
type ActionResult struct {
Announcement string
HandOver bool // only 1 player remains
StreetOver bool // street betting is complete
AllAllIn bool // all remaining players are all-in
}
// doFold processes a fold action.
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
p := g.Players[seatIdx]
p.State = PlayerFolded
g.StreetHistory += "f"
ann := renderActionAnnouncement(p.DisplayName, "fold", 0)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}
}
// doCheck processes a check action. Returns error string if invalid.
func (g *HoldemGame) doCheck(seatIdx int) (ActionResult, string) {
p := g.Players[seatIdx]
if p.Bet < g.CurrentBet {
return ActionResult{}, "You must call, raise, or fold — there's a bet to you."
}
g.StreetHistory += "c"
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
}, ""
}
// doCall processes a call action. Returns error string if nothing to call.
func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) {
p := g.Players[seatIdx]
toCall := g.CurrentBet - p.Bet
if toCall <= 0 {
return ActionResult{}, "Nothing to call. Use `!holdem check` instead."
}
if toCall > p.Stack {
toCall = p.Stack
}
p.Stack -= toCall
p.Bet += toCall
p.TotalBet += toCall
action := "call"
if p.Stack == 0 {
p.State = PlayerAllIn
action = "allin"
g.StreetHistory += "a"
} else {
g.StreetHistory += "c"
}
ann := renderActionAnnouncement(p.DisplayName, action, toCall)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}, ""
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}, ""
}
// doRaise processes a raise action. raiseTo is the total bet amount.
func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string) {
p := g.Players[seatIdx]
minRaiseTo := g.CurrentBet + g.MinRaise
maxRaiseTo := p.Bet + p.Stack
if raiseTo < minRaiseTo && raiseTo < maxRaiseTo {
return ActionResult{}, fmt.Sprintf("Minimum raise is to €%d.", minRaiseTo)
}
if raiseTo > maxRaiseTo {
return ActionResult{}, fmt.Sprintf("You can raise to at most €%d (your stack).", maxRaiseTo)
}
raiseAmount := raiseTo - p.Bet
actualRaise := raiseTo - g.CurrentBet
p.Stack -= raiseAmount
p.Bet = raiseTo
p.TotalBet += raiseAmount
if actualRaise > 0 {
g.MinRaise = actualRaise
}
g.CurrentBet = raiseTo
g.LastAggressorIdx = seatIdx
action := "raise"
if p.Stack == 0 {
p.State = PlayerAllIn
action = "allin"
g.StreetHistory += "a"
} else {
// Approximate: >=75% of pot is a pot-size raise ('R'), otherwise half-pot ('r').
totalPot := g.Pot
for _, pp := range g.Players {
totalPot += pp.Bet
}
if totalPot > 0 && float64(actualRaise) >= float64(totalPot)*0.75 {
g.StreetHistory += "R"
} else {
g.StreetHistory += "r"
}
}
ann := renderActionAnnouncement(p.DisplayName, action, raiseTo)
return ActionResult{
Announcement: ann,
AllAllIn: g.canActCount() == 0,
}, ""
}
// doAllIn processes an all-in action.
func (g *HoldemGame) doAllIn(seatIdx int) ActionResult {
p := g.Players[seatIdx]
allInAmount := p.Stack
totalBet := p.Bet + allInAmount
p.Stack = 0
p.Bet = totalBet
p.TotalBet += allInAmount
p.State = PlayerAllIn
if totalBet > g.CurrentBet {
actualRaise := totalBet - g.CurrentBet
// Only reopen action if the raise meets the minimum.
// A short all-in (under-raise) does NOT reopen betting.
if actualRaise >= g.MinRaise {
g.MinRaise = actualRaise
g.LastAggressorIdx = seatIdx
}
g.CurrentBet = totalBet
}
g.StreetHistory += "a"
ann := renderActionAnnouncement(p.DisplayName, "allin", totalBet)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}
}
// isStreetComplete checks if the betting round is done.
func (g *HoldemGame) isStreetComplete(nextIdx int) bool {
// All active players have matched the current bet and action has returned to the last aggressor.
if g.canActCount() == 0 {
return true
}
// Check if all Active players have matched the bet.
for _, p := range g.Players {
if p.State == PlayerActive && p.Bet != g.CurrentBet {
return false
}
}
// If the last aggressor is all-in (can't act), the street is done when
// all active players have matched the bet (already checked above).
if g.Players[g.LastAggressorIdx].State == PlayerAllIn {
return true
}
// Action must have gone around to the last aggressor.
return nextIdx == g.LastAggressorIdx
}
// buildSidePots creates side pots when all-ins are present.
func (g *HoldemGame) buildSidePots() {
// Collect all bets.
g.collectPot()
type betEntry struct {
uid id.UserID
bet int64
}
var entries []betEntry
for _, p := range g.Players {
if p.State != PlayerFolded && p.State != PlayerSatOut {
entries = append(entries, betEntry{p.UserID, p.TotalBet})
}
}
if len(entries) == 0 {
return
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].bet < entries[j].bet
})
var sidePots []SidePot
prevLevel := int64(0)
for _, e := range entries {
if e.bet <= prevLevel {
continue
}
level := e.bet
potSlice := int64(0)
// All players who bet >= level contribute (level - prevLevel) each.
var eligible []id.UserID
for _, p := range g.Players {
if p.State == PlayerFolded || p.State == PlayerSatOut {
// Folded players still contributed up to their TotalBet.
contrib := p.TotalBet - prevLevel
if contrib > level-prevLevel {
contrib = level - prevLevel
}
if contrib > 0 {
potSlice += contrib
}
continue
}
contrib := p.TotalBet - prevLevel
if contrib > level-prevLevel {
contrib = level - prevLevel
}
if contrib > 0 {
potSlice += contrib
}
if p.TotalBet >= level {
eligible = append(eligible, p.UserID)
}
}
if potSlice > 0 {
sidePots = append(sidePots, SidePot{Amount: potSlice, Eligible: eligible})
}
prevLevel = level
}
if len(sidePots) > 0 {
g.SidePots = sidePots
g.Pot = 0
}
}
// returnUncalledBet returns any unmatched portion of a bet to the player.
func (g *HoldemGame) returnUncalledBet() (name string, amount int64) {
// Find the highest and second-highest bets among non-folded players.
var highest, secondHighest int64
var highestIdx int
for i, p := range g.Players {
if p.State == PlayerFolded || p.State == PlayerSatOut {
continue
}
if p.TotalBet > highest {
secondHighest = highest
highest = p.TotalBet
highestIdx = i
} else if p.TotalBet > secondHighest {
secondHighest = p.TotalBet
}
}
excess := highest - secondHighest
if excess > 0 && secondHighest > 0 {
p := g.Players[highestIdx]
p.Stack += excess
p.TotalBet -= excess
p.Bet -= excess
if p.Bet < 0 {
p.Bet = 0
}
return p.DisplayName, excess
}
return "", 0
}