mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
36
cmd/holdem-seed/main.go
Normal file
36
cmd/holdem-seed/main.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build training
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
)
|
||||
|
||||
// Creates a minimal seed policy so the bot can start without a trained policy.
|
||||
// Run: go run -tags training ./cmd/holdem-seed/
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
data := &plugin.CFRData{
|
||||
Regrets: make(plugin.RegretTable),
|
||||
Strategy: make(plugin.RegretTable),
|
||||
Meta: plugin.CFRTrainingMeta{
|
||||
Iterations: 0,
|
||||
Seed: 42,
|
||||
Date: "seed-policy",
|
||||
},
|
||||
}
|
||||
|
||||
os.MkdirAll("data", 0o755)
|
||||
if err := plugin.SaveCFRData("data/policy.gob", data); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("Seed policy created", "path", "data/policy.gob")
|
||||
slog.Info("Train a real policy with: go run -tags training ./cmd/holdem-train/ --iterations 1000000 --workers 8")
|
||||
}
|
||||
238
cmd/holdem-train/main.go
Normal file
238
cmd/holdem-train/main.go
Normal file
@@ -0,0 +1,238 @@
|
||||
//go:build training
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
iterations := flag.Int("iterations", 5_000_000, "Number of training iterations")
|
||||
workers := flag.Int("workers", runtime.NumCPU(), "Number of parallel workers")
|
||||
output := flag.String("output", "data/policy.gob", "Output policy file path")
|
||||
resume := flag.String("resume", "", "Resume from checkpoint file")
|
||||
validate := flag.Bool("validate", false, "Run validation instead of training")
|
||||
checkpointEvery := flag.Int("checkpoint-every", 500_000, "Save checkpoint every N iterations")
|
||||
seed := flag.Int64("seed", 42, "Random seed for reproducibility")
|
||||
flag.Parse()
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
if *validate {
|
||||
runValidation(*output)
|
||||
return
|
||||
}
|
||||
|
||||
runTraining(*iterations, *workers, *output, *resume, *checkpointEvery, *seed)
|
||||
}
|
||||
|
||||
func runTraining(iterations, workers int, output, resumePath string, checkpointEvery int, seed int64) {
|
||||
var data *plugin.CFRData
|
||||
|
||||
if resumePath != "" {
|
||||
slog.Info("Resuming from checkpoint", "path", resumePath)
|
||||
var err error
|
||||
data, err = plugin.LoadCFRData(resumePath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to load checkpoint", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Checkpoint loaded",
|
||||
"existing_iterations", data.Meta.Iterations,
|
||||
"nodes", len(data.Regrets))
|
||||
} else {
|
||||
data = &plugin.CFRData{
|
||||
Regrets: make(plugin.RegretTable),
|
||||
Strategy: make(plugin.RegretTable),
|
||||
Meta: plugin.CFRTrainingMeta{
|
||||
Seed: seed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Starting CFR training",
|
||||
"iterations", iterations,
|
||||
"workers", workers,
|
||||
"output", output)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
progress := &plugin.TrainProgress{
|
||||
Total: iterations,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
if workers <= 1 {
|
||||
// Single-threaded training.
|
||||
plugin.TrainCFR(data, iterations, 100_000, "", progress)
|
||||
data.Meta.Iterations += iterations
|
||||
} else {
|
||||
// Parallel training with shared regret table.
|
||||
var mu sync.Mutex
|
||||
iterPerWorker := iterations / workers
|
||||
remainder := iterations % workers
|
||||
|
||||
var wg sync.WaitGroup
|
||||
totalCompleted := 0
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
iters := iterPerWorker
|
||||
if w < remainder {
|
||||
iters++
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(workerID, workerIters int) {
|
||||
defer wg.Done()
|
||||
|
||||
// Each worker trains on its own local data, then merges.
|
||||
localData := &plugin.CFRData{
|
||||
Regrets: make(plugin.RegretTable),
|
||||
Strategy: make(plugin.RegretTable),
|
||||
}
|
||||
|
||||
progressEvery := 100_000 / workers
|
||||
if progressEvery < 10_000 {
|
||||
progressEvery = 10_000
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("worker-%d", workerID)
|
||||
plugin.TrainCFR(localData, workerIters, progressEvery, label, progress)
|
||||
|
||||
// Merge local data into shared data.
|
||||
mu.Lock()
|
||||
for key, regrets := range localData.Regrets {
|
||||
existing := data.Regrets[key]
|
||||
for i := range regrets {
|
||||
existing[i] += regrets[i]
|
||||
}
|
||||
data.Regrets[key] = existing
|
||||
}
|
||||
for key, strat := range localData.Strategy {
|
||||
existing := data.Strategy[key]
|
||||
for i := range strat {
|
||||
existing[i] += strat[i]
|
||||
}
|
||||
data.Strategy[key] = existing
|
||||
}
|
||||
totalCompleted += workerIters
|
||||
mu.Unlock()
|
||||
|
||||
slog.Info("Worker completed", "worker", workerID, "iterations", workerIters)
|
||||
}(w, iters)
|
||||
}
|
||||
|
||||
// Checkpoint saver (runs in background).
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second) // checkpoint every 30s
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
mu.Lock()
|
||||
nodeCount := len(data.Regrets)
|
||||
if nodeCount == 0 {
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
// Deep copy maps to avoid concurrent read during gob encoding.
|
||||
checkpoint := &plugin.CFRData{
|
||||
Regrets: make(plugin.RegretTable, nodeCount),
|
||||
Strategy: make(plugin.RegretTable, len(data.Strategy)),
|
||||
Meta: data.Meta,
|
||||
}
|
||||
for k, v := range data.Regrets {
|
||||
checkpoint.Regrets[k] = v
|
||||
}
|
||||
for k, v := range data.Strategy {
|
||||
checkpoint.Strategy[k] = v
|
||||
}
|
||||
checkpoint.Meta.Iterations += totalCompleted
|
||||
mu.Unlock()
|
||||
|
||||
checkpointPath := output + ".checkpoint"
|
||||
if err := plugin.SaveCFRData(checkpointPath, checkpoint); err != nil {
|
||||
slog.Warn("Checkpoint save failed", "err", err)
|
||||
} else {
|
||||
slog.Info("Checkpoint saved", "path", checkpointPath, "nodes", len(checkpoint.Regrets))
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(done)
|
||||
data.Meta.Iterations += iterations
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
data.Meta.Date = time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
slog.Info("Training complete",
|
||||
"iterations", data.Meta.Iterations,
|
||||
"nodes", len(data.Regrets),
|
||||
"elapsed", elapsed.Round(time.Second))
|
||||
|
||||
// Ensure output directory exists.
|
||||
if dir := outputDir(output); dir != "" {
|
||||
os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
if err := plugin.SaveCFRData(output, data); err != nil {
|
||||
slog.Error("Failed to save policy", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("Policy saved", "path", output, "nodes", len(data.Strategy))
|
||||
}
|
||||
|
||||
func runValidation(policyPath string) {
|
||||
slog.Info("Loading policy for validation", "path", policyPath)
|
||||
|
||||
policy, err := plugin.LoadPolicy(policyPath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to load policy", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("Running validation (50,000 hands)...")
|
||||
winRate, vpip, aggFactor := plugin.ValidatePolicy(policy, 50_000)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Validation Results ===")
|
||||
fmt.Printf("Win Rate: %.1f%%\n", winRate*100)
|
||||
fmt.Printf("VPIP: %.1f%%\n", vpip*100)
|
||||
fmt.Printf("Agg Factor: %.2f\n", aggFactor)
|
||||
fmt.Println()
|
||||
|
||||
if winRate > 0.55 {
|
||||
fmt.Println("✅ Policy exceeds 55% win rate threshold.")
|
||||
os.Exit(0)
|
||||
} else if winRate > 0.50 {
|
||||
fmt.Println("⚠️ Policy above 50% but below 55% threshold. Consider more training iterations.")
|
||||
os.Exit(0)
|
||||
} else {
|
||||
fmt.Println("❌ Policy below 50% win rate. More training needed.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func outputDir(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user