mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
239 lines
6.0 KiB
Go
239 lines
6.0 KiB
Go
//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 ""
|
|
}
|