Compare commits
4 Commits
6e20883e5d
...
638e28263a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
638e28263a | ||
|
|
b96879d25c | ||
|
|
903c5accdb | ||
|
|
e6c1bd3b54 |
89
cmd/holdem-train/main.go
Normal file
89
cmd/holdem-train/main.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Command holdem-train trains the casino's poker bots and writes the policy the
|
||||||
|
// table embeds.
|
||||||
|
//
|
||||||
|
// go run ./cmd/holdem-train -iterations 5000000 -out internal/games/holdem/policy.gob
|
||||||
|
//
|
||||||
|
// It is not part of Pete. It runs when the engine's rules change, takes half an
|
||||||
|
// hour, and produces a file. Nothing at runtime imports it.
|
||||||
|
//
|
||||||
|
// The bots are trained heads-up, at every stack depth the table deals — that
|
||||||
|
// range is the point, because poker at twenty big blinds and poker at a hundred
|
||||||
|
// are different games and a bot that only knows one of them folds into the other.
|
||||||
|
// A six-handed table then reuses the same policy, which is an approximation, and
|
||||||
|
// a documented one.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/games/holdem"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iterations := flag.Int("iterations", 5_000_000, "hands to train on")
|
||||||
|
workers := flag.Int("workers", runtime.NumCPU(), "parallel workers")
|
||||||
|
out := flag.String("out", "internal/games/holdem/policy.gob", "where to write the policy")
|
||||||
|
stakes := flag.String("stakes", "low", "which table's blinds to train at")
|
||||||
|
minBB := flag.Int64("min-bb", 20, "shallowest stack, in big blinds")
|
||||||
|
maxBB := flag.Int64("max-bb", 100, "deepest stack, in big blinds")
|
||||||
|
seed := flag.Uint64("seed", 20260714, "seed, so a run can be repeated")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
tier, err := holdem.TierBySlug(*stakes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("no such table %q", *stakes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("training %s hands on %d workers — %s blinds, %d–%d BB deep\n",
|
||||||
|
commas(*iterations), *workers, tier.Name, *minBB, *maxBB)
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
last := started
|
||||||
|
policy := holdem.Train(*iterations, *workers, tier, *minBB, *maxBB, *seed, func(done int) {
|
||||||
|
if time.Since(last) < 20*time.Second {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last = time.Now()
|
||||||
|
frac := float64(done) / float64(*iterations)
|
||||||
|
if frac <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
left := time.Duration(float64(time.Since(started)) * (1 - frac) / frac)
|
||||||
|
fmt.Printf(" %s / %s hands (%.0f%%), about %s to go\n",
|
||||||
|
commas(done), commas(*iterations), frac*100, left.Round(time.Second))
|
||||||
|
})
|
||||||
|
|
||||||
|
f, err := os.Create(*out)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := holdem.Save(f, policy); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, _ := f.Stat()
|
||||||
|
fmt.Printf("\ndone in %s: %s nodes, %.1f MB → %s\n",
|
||||||
|
time.Since(started).Round(time.Second), commas(policy.Meta.Nodes),
|
||||||
|
float64(info.Size())/(1<<20), *out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commas(n int) string {
|
||||||
|
s := fmt.Sprint(n)
|
||||||
|
if len(s) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
for i, c := range []byte(s) {
|
||||||
|
if i > 0 && (len(s)-i)%3 == 0 {
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/chehsunliu/poker v0.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
|
|||||||
7
go.sum
7
go.sum
@@ -10,6 +10,8 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1
|
|||||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
|
||||||
|
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -31,6 +33,7 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
@@ -48,6 +51,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -58,6 +62,7 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
|||||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
@@ -157,6 +162,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
|||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||||
|
|||||||
279
internal/games/holdem/betting.go
Normal file
279
internal/games/holdem/betting.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// The betting rules. These are the fiddly ones — min-raise, short all-ins that
|
||||||
|
// don't reopen the action, side pots — and they came over from gogobee, where
|
||||||
|
// they had no tests at all. They have some now.
|
||||||
|
|
||||||
|
// blinds posts the small and the big. Heads-up is the exception every poker
|
||||||
|
// implementation gets wrong once: with two players the button *is* the small
|
||||||
|
// blind and acts first before the flop, and last after it.
|
||||||
|
func (s *State) blinds(evs *[]Event) (bb int) {
|
||||||
|
var sb int
|
||||||
|
if s.dealt() == 2 {
|
||||||
|
sb, bb = s.Button, s.nextIn(s.Button)
|
||||||
|
} else {
|
||||||
|
sb = s.nextIn(s.Button)
|
||||||
|
bb = s.nextIn(sb)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.post(sb, s.Tier.SB, "small", evs)
|
||||||
|
s.post(bb, s.Tier.BB, "big", evs)
|
||||||
|
|
||||||
|
s.Bet = s.Tier.BB
|
||||||
|
s.MinRaise = s.Tier.BB
|
||||||
|
s.Aggressor = bb // the big blind has the option to raise their own blind
|
||||||
|
return bb
|
||||||
|
}
|
||||||
|
|
||||||
|
// post puts a blind up. A player too short to cover it is all-in for what they
|
||||||
|
// have, which is legal and is why the amount is clamped rather than refused.
|
||||||
|
func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
if amount > p.Stack {
|
||||||
|
amount = p.Stack
|
||||||
|
}
|
||||||
|
p.Stack -= amount
|
||||||
|
p.Bet = amount
|
||||||
|
p.Total = amount
|
||||||
|
if p.Stack == 0 {
|
||||||
|
p.State = AllIn
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstPreFlop is under the gun: the seat after the big blind, or the button
|
||||||
|
// itself when the table is heads-up.
|
||||||
|
//
|
||||||
|
// The button only gets it if the button can still act. A short stack can be
|
||||||
|
// all-in on its own blind — post a small blind of 1 with 1 chip left and you are
|
||||||
|
// in the hand with no chips and no say — and handing the action to a seat that
|
||||||
|
// cannot act wedges the table.
|
||||||
|
func (s *State) firstPreFlop(bb int) int {
|
||||||
|
if s.dealt() != 2 {
|
||||||
|
return s.nextCanAct(bb)
|
||||||
|
}
|
||||||
|
if s.Seats[s.Button].State == Active {
|
||||||
|
return s.Button
|
||||||
|
}
|
||||||
|
return s.nextCanAct(s.Button)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstPostFlop is the first seat left of the button, on every street after the
|
||||||
|
// flop. The button acts last from here on, which is the whole point of it.
|
||||||
|
func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) }
|
||||||
|
|
||||||
|
// ---- the five things a seat can do ----------------------------------------
|
||||||
|
|
||||||
|
func (s *State) fold(seat int, evs *[]Event) {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
p.State = Folded
|
||||||
|
p.Acted = true
|
||||||
|
s.History += "f"
|
||||||
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) check(seat int, evs *[]Event) error {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
if p.Bet < s.Bet {
|
||||||
|
return ErrCantCheck
|
||||||
|
}
|
||||||
|
p.Acted = true
|
||||||
|
s.History += "c"
|
||||||
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) call(seat int, evs *[]Event) error {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
owed := s.Bet - p.Bet
|
||||||
|
if owed <= 0 {
|
||||||
|
return ErrNothingToCall
|
||||||
|
}
|
||||||
|
if owed > p.Stack {
|
||||||
|
owed = p.Stack // a call for less than the bet is a call all-in
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Stack -= owed
|
||||||
|
p.Bet += owed
|
||||||
|
p.Total += owed
|
||||||
|
p.Acted = true
|
||||||
|
|
||||||
|
text := "call"
|
||||||
|
if p.Stack == 0 {
|
||||||
|
p.State = AllIn
|
||||||
|
text = "allin"
|
||||||
|
s.History += "a"
|
||||||
|
} else {
|
||||||
|
s.History += "c"
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// raise raises *to* a total, not *by* an amount. Every poker interface in the
|
||||||
|
// world means the total, and a browser that means the other thing bets wrong.
|
||||||
|
func (s *State) raise(seat int, to int64, evs *[]Event) error {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
most := p.Bet + p.Stack
|
||||||
|
|
||||||
|
if to > most {
|
||||||
|
return ErrTooBig
|
||||||
|
}
|
||||||
|
if to < s.Bet+s.MinRaise && to < most {
|
||||||
|
return ErrTooSmall // only a shove may be smaller than a legal raise
|
||||||
|
}
|
||||||
|
|
||||||
|
added := to - p.Bet
|
||||||
|
over := to - s.Bet
|
||||||
|
|
||||||
|
p.Stack -= added
|
||||||
|
p.Bet = to
|
||||||
|
p.Total += added
|
||||||
|
p.Acted = true
|
||||||
|
|
||||||
|
if over > 0 {
|
||||||
|
s.MinRaise = over
|
||||||
|
}
|
||||||
|
s.Bet = to
|
||||||
|
s.Aggressor = seat
|
||||||
|
|
||||||
|
text := "raise"
|
||||||
|
if p.Stack == 0 {
|
||||||
|
p.State = AllIn
|
||||||
|
text = "allin"
|
||||||
|
s.History += "a"
|
||||||
|
} else {
|
||||||
|
// The policy was trained against a tree with two raise sizes in it, so the
|
||||||
|
// history it reads has to say which one this was: R for a pot-sized raise
|
||||||
|
// or bigger, r for anything smaller.
|
||||||
|
if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 {
|
||||||
|
s.History += "R"
|
||||||
|
} else {
|
||||||
|
s.History += "r"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allin pushes the lot.
|
||||||
|
//
|
||||||
|
// A short all-in does not reopen the betting. If a player shoves for less than
|
||||||
|
// a full raise over the current bet, players who have already acted may call it
|
||||||
|
// but may not raise again — otherwise a tiny stack could be used to reopen the
|
||||||
|
// action for a partner, which is the oldest collusion trick there is.
|
||||||
|
func (s *State) allin(seat int, evs *[]Event) error {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
if p.Stack <= 0 {
|
||||||
|
return ErrNoChips
|
||||||
|
}
|
||||||
|
added := p.Stack
|
||||||
|
to := p.Bet + added
|
||||||
|
|
||||||
|
p.Stack = 0
|
||||||
|
p.Bet = to
|
||||||
|
p.Total += added
|
||||||
|
p.State = AllIn
|
||||||
|
p.Acted = true
|
||||||
|
|
||||||
|
if to > s.Bet {
|
||||||
|
if over := to - s.Bet; over >= s.MinRaise {
|
||||||
|
s.MinRaise = over
|
||||||
|
s.Aggressor = seat
|
||||||
|
}
|
||||||
|
s.Bet = to
|
||||||
|
}
|
||||||
|
|
||||||
|
s.History += "a"
|
||||||
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- when is a street over ------------------------------------------------
|
||||||
|
|
||||||
|
// streetDone reports whether the betting round is finished, given the seat the
|
||||||
|
// action would pass to next.
|
||||||
|
//
|
||||||
|
// The "has acted" check is the load-bearing half. The big blind has money in
|
||||||
|
// front of them without having chosen to put it there, so a round where
|
||||||
|
// everybody merely limps in has all bets matched while the blind has never had
|
||||||
|
// a say. Without this, they never get their option.
|
||||||
|
func (s *State) streetDone(next int) bool {
|
||||||
|
if s.canActCount() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
if p.State != Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Bet != s.Bet || !p.Acted {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The last aggressor being all-in means the action can't get back to them:
|
||||||
|
// everyone left has matched the bet above, so there is nothing more to do.
|
||||||
|
if s.Seats[s.Aggressor].State == AllIn {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return next == s.Aggressor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- side pots -------------------------------------------------------------
|
||||||
|
|
||||||
|
// sidePots slices the pot into layers, one per distinct all-in level. A player
|
||||||
|
// can only win the part of the pot they could have lost, so each layer is
|
||||||
|
// contested by exactly the players who paid into it.
|
||||||
|
//
|
||||||
|
// Folded players' chips stay in the pot — they paid for the right to fold — but
|
||||||
|
// they are eligible for nothing.
|
||||||
|
func (s *State) sidePots() {
|
||||||
|
s.collect()
|
||||||
|
|
||||||
|
var levels []int64
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
if p.State == Folded || p.State == Out || p.Total == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
levels = append(levels, p.Total)
|
||||||
|
}
|
||||||
|
if len(levels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] })
|
||||||
|
|
||||||
|
var pots []Pot
|
||||||
|
var prev int64
|
||||||
|
for _, level := range levels {
|
||||||
|
if level <= prev {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var amount int64
|
||||||
|
var eligible []int
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
paid := p.Total - prev
|
||||||
|
if paid > level-prev {
|
||||||
|
paid = level - prev
|
||||||
|
}
|
||||||
|
if paid > 0 {
|
||||||
|
amount += paid // folded money counts toward the pot...
|
||||||
|
}
|
||||||
|
if p.State != Folded && p.State != Out && p.Total >= level {
|
||||||
|
eligible = append(eligible, i) // ...but wins no part of it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if amount > 0 {
|
||||||
|
pots = append(pots, Pot{Amount: amount, Eligible: eligible})
|
||||||
|
}
|
||||||
|
prev = level
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pots) > 0 {
|
||||||
|
s.Side = pots
|
||||||
|
s.Pot = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
416
internal/games/holdem/cfr.go
Normal file
416
internal/games/holdem/cfr.go
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bots' brain.
|
||||||
|
//
|
||||||
|
// gogobee ran counterfactual regret minimisation against this game for a very
|
||||||
|
// long time, and policy.gob is what it converged on: a table from "the situation
|
||||||
|
// I am in" to "how often I fold, call, raise small, raise big, or shove". It is
|
||||||
|
// the single highest-value thing in either repository, and none of it is
|
||||||
|
// re-derived here — this file is the *runtime*, the trainer stayed behind.
|
||||||
|
//
|
||||||
|
// A situation is squeezed down to six things, and this is the whole reason the
|
||||||
|
// table fits in memory: the street, whether the bot is in position, which of
|
||||||
|
// twelve equity buckets its hand falls in, which of five stack-to-pot buckets,
|
||||||
|
// whether the board is dry, wet or paired, and the last six actions. Two hands
|
||||||
|
// that hash to the same key get the same strategy, and that is the approximation
|
||||||
|
// the whole thing is built on.
|
||||||
|
//
|
||||||
|
// The key has to be *exactly* the key the trainer wrote, character for
|
||||||
|
// character. Change the bucket edges, the position label or the history encoding
|
||||||
|
// and every lookup misses — silently, because a miss is not an error, it is a
|
||||||
|
// fall back to the pot-odds rule below. The bots would get quietly, unaccountably
|
||||||
|
// worse. So: don't touch these numbers.
|
||||||
|
|
||||||
|
//go:embed policy.gob
|
||||||
|
var policyGob []byte
|
||||||
|
|
||||||
|
// The five things the trainer let a bot consider.
|
||||||
|
const (
|
||||||
|
actFold = iota
|
||||||
|
actCallCheck
|
||||||
|
actRaiseHalf
|
||||||
|
actRaisePot
|
||||||
|
actAllIn
|
||||||
|
numActions
|
||||||
|
)
|
||||||
|
|
||||||
|
// policyTable maps an info-set key to how often to take each action.
|
||||||
|
type policyTable map[string][numActions]float64
|
||||||
|
|
||||||
|
var (
|
||||||
|
policyOnce sync.Once
|
||||||
|
policy policyTable
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadPolicy decodes the embedded table, once, on the first hand anybody plays.
|
||||||
|
//
|
||||||
|
// Not in an init(): it is megabytes of gob, and Pete is a news server that mostly
|
||||||
|
// never deals a card. Every test in the repo and every cold start would pay for
|
||||||
|
// it. The first player to sit down pays for it instead, and only they do.
|
||||||
|
func loadPolicy() policyTable {
|
||||||
|
policyOnce.Do(func() {
|
||||||
|
t, err := loadTrained(policyGob)
|
||||||
|
if err != nil {
|
||||||
|
// The bots still play — on pot odds — rather than the table 500ing.
|
||||||
|
slog.Error("holdem: cannot decode CFR policy, bots fall back to pot odds", "err", err)
|
||||||
|
policy = policyTable{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy = t.Strategy
|
||||||
|
slog.Info("holdem: CFR policy loaded", "nodes", len(policy),
|
||||||
|
"iterations", t.Meta.Iterations, "stakes", t.Meta.Stakes, "depths", t.Meta.Depths)
|
||||||
|
})
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the info-set key ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Every function below is a load-bearing copy of the trainer's. See the note at
|
||||||
|
// the top of the file before changing a number in any of them.
|
||||||
|
|
||||||
|
// equityBucket puts a hand's strength in one of twelve boxes, from trash to
|
||||||
|
// monster.
|
||||||
|
func equityBucket(eq float64) int {
|
||||||
|
switch {
|
||||||
|
case eq < 0.08:
|
||||||
|
return 0
|
||||||
|
case eq < 0.17:
|
||||||
|
return 1
|
||||||
|
case eq < 0.25:
|
||||||
|
return 2
|
||||||
|
case eq < 0.33:
|
||||||
|
return 3
|
||||||
|
case eq < 0.42:
|
||||||
|
return 4
|
||||||
|
case eq < 0.50:
|
||||||
|
return 5
|
||||||
|
case eq < 0.58:
|
||||||
|
return 6
|
||||||
|
case eq < 0.67:
|
||||||
|
return 7
|
||||||
|
case eq < 0.75:
|
||||||
|
return 8
|
||||||
|
case eq < 0.83:
|
||||||
|
return 9
|
||||||
|
case eq < 0.92:
|
||||||
|
return 10
|
||||||
|
default:
|
||||||
|
return 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sprBucket is the stack-to-pot ratio: how much room is left to play. Under 1 is
|
||||||
|
// a pot that is already committed; over 12 is a pot you can still fold out of.
|
||||||
|
func sprBucket(spr float64) int {
|
||||||
|
switch {
|
||||||
|
case spr < 1:
|
||||||
|
return 0
|
||||||
|
case spr < 3:
|
||||||
|
return 1
|
||||||
|
case spr < 6:
|
||||||
|
return 2
|
||||||
|
case spr < 12:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board textures, which is what makes the same hand a bet or a check.
|
||||||
|
const (
|
||||||
|
boardDry = 0
|
||||||
|
boardWet = 1
|
||||||
|
boardPaired = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// boardTexture classifies the community cards. A paired board is one somebody
|
||||||
|
// might have trips on; a wet one has a flush or straight coming.
|
||||||
|
// It is on the trainer's hot path — millions of calls — so it counts into arrays
|
||||||
|
// rather than maps. A map here cost more than the poker did.
|
||||||
|
func boardTexture(board []cards.Card) int {
|
||||||
|
if len(board) < 3 {
|
||||||
|
return boardDry
|
||||||
|
}
|
||||||
|
|
||||||
|
var ranks [14]int8
|
||||||
|
var suits [4]int8
|
||||||
|
var vals [5]int
|
||||||
|
for i, c := range board {
|
||||||
|
ranks[c.Rank]++
|
||||||
|
suits[c.Suit]++
|
||||||
|
vals[i] = int(c.Rank)
|
||||||
|
}
|
||||||
|
for _, n := range ranks {
|
||||||
|
if n >= 2 {
|
||||||
|
return boardPaired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range suits {
|
||||||
|
if n >= 3 {
|
||||||
|
return boardWet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three cards inside a five-rank window is a straight waiting to happen.
|
||||||
|
v := vals[:len(board)]
|
||||||
|
for i := 1; i < len(v); i++ {
|
||||||
|
for j := i; j > 0 && v[j] < v[j-1]; j-- {
|
||||||
|
v[j], v[j-1] = v[j-1], v[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i+2 < len(v); i++ {
|
||||||
|
if v[i+2]-v[i] <= 4 {
|
||||||
|
return boardWet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return boardDry
|
||||||
|
}
|
||||||
|
|
||||||
|
// infoSet builds the string the policy is keyed on. The format is the trainer's.
|
||||||
|
//
|
||||||
|
// Position is IP or OOP — in position or out of it — and *nothing else*. This is
|
||||||
|
// the one thing gogobee got wrong, and it got it wrong invisibly for as long as
|
||||||
|
// the game has existed: the trainer packed a single "am I last to act" bit and
|
||||||
|
// wrote its keys as IP/OOP, while the runtime looked them up with the table
|
||||||
|
// labels a player would recognise (BTN, SB, BB, UTG…). Not one key ever matched.
|
||||||
|
// Every bot in every hand of hold'em gogobee ever dealt fell through to the
|
||||||
|
// pot-odds rule, and the five million training iterations sitting in policy.gob
|
||||||
|
// were never once read.
|
||||||
|
//
|
||||||
|
// Nothing about that looks broken from the outside. A missing key is not an
|
||||||
|
// error, it's a fallback — the bots played, they just played a heuristic. This is
|
||||||
|
// why the hit rate is now a test.
|
||||||
|
func infoSet(street Street, inPosition bool, eq, spr, texture int, history string) string {
|
||||||
|
pos := "OOP"
|
||||||
|
if inPosition {
|
||||||
|
pos = "IP"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d|%s|%d|%d|%d|%s", street, pos, eq, spr, texture, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recent keeps the last six actions, which is all the trainer's key had room for.
|
||||||
|
func recent(h string) string {
|
||||||
|
if len(h) > 6 {
|
||||||
|
return h[len(h)-6:]
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- where a seat stands ---------------------------------------------------
|
||||||
|
|
||||||
|
// mcIters is how many runouts a bot samples to judge its hand at the table.
|
||||||
|
const mcIters = 1000
|
||||||
|
|
||||||
|
// spot is everything the policy knows about a seat's situation, and it is the
|
||||||
|
// one function that builds it.
|
||||||
|
//
|
||||||
|
// The trainer calls this too. That is the point of it: the key the policy is
|
||||||
|
// written under and the key it is read under come out of the same code, so they
|
||||||
|
// cannot quietly stop matching — which is exactly what went wrong the first time
|
||||||
|
// and went unnoticed for the life of the game.
|
||||||
|
func (s State) spot(seat, iters int, rng *rand.Rand) (string, Equity) {
|
||||||
|
eq := s.equityFor(seat, iters, rng)
|
||||||
|
return s.spotKey(seat, eq), eq
|
||||||
|
}
|
||||||
|
|
||||||
|
// equityFor measures how good a seat's hand is right now.
|
||||||
|
//
|
||||||
|
// It depends only on the cards — the hand, the board, how many opponents — and
|
||||||
|
// not on a single thing that happened in the betting. Which is why the trainer
|
||||||
|
// can measure it once per street and reuse it down every branch it explores, and
|
||||||
|
// why doing that is most of the difference between a run that takes half an hour
|
||||||
|
// and one that takes four.
|
||||||
|
func (s State) equityFor(seat, iters int, rng *rand.Rand) Equity {
|
||||||
|
opponents := s.liveCount() - 1
|
||||||
|
if opponents < 1 {
|
||||||
|
opponents = 1
|
||||||
|
}
|
||||||
|
// Preflop heads-up is a lookup, not a simulation: there are only 169 hands
|
||||||
|
// that differ, they have been measured to death, and a sampled answer would
|
||||||
|
// only add noise to a bucket boundary.
|
||||||
|
if s.Street == PreFlop && opponents == 1 {
|
||||||
|
return preflopEquity(s.Seats[seat].Hole)
|
||||||
|
}
|
||||||
|
return equityOf(s.Seats[seat].Hole, s.Community, opponents, iters, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotKey builds the key from an equity already measured.
|
||||||
|
func (s State) spotKey(seat int, eq Equity) string {
|
||||||
|
pot := s.inPlay()
|
||||||
|
spr := 0.0
|
||||||
|
if pot > 0 {
|
||||||
|
spr = float64(s.Seats[seat].Stack) / float64(pot)
|
||||||
|
}
|
||||||
|
return infoSet(s.Street, s.InPosition(seat), equityBucket(eq.Strength()),
|
||||||
|
sprBucket(spr), boardTexture(s.Community), recent(s.History))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- choosing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// hits and misses count how often a bot finds itself in the trained policy.
|
||||||
|
//
|
||||||
|
// They exist because the way this can break is silently. A key the policy has
|
||||||
|
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
|
||||||
|
// that has stopped matching the game looks exactly like a policy that is working.
|
||||||
|
// gogobee's never matched once, for the whole life of the game, and nobody could
|
||||||
|
// have known by watching it play. Now a test reads these and fails.
|
||||||
|
var hits, misses atomic.Int64
|
||||||
|
|
||||||
|
// botActs plays one bot's turn: work out where it stands, look up what it does
|
||||||
|
// there, throw out anything illegal, and roll for it.
|
||||||
|
func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
|
||||||
|
key, eq := s.spot(seat, mcIters, rng)
|
||||||
|
|
||||||
|
probs, ok := loadPolicy()[key]
|
||||||
|
if ok {
|
||||||
|
hits.Add(1)
|
||||||
|
} else {
|
||||||
|
misses.Add(1)
|
||||||
|
probs = potOdds(eq, s, seat)
|
||||||
|
}
|
||||||
|
probs = legal(probs, s, seat)
|
||||||
|
|
||||||
|
move := s.moveFor(pick(probs, rng), seat)
|
||||||
|
if err := s.act(seat, move, evs); err != nil {
|
||||||
|
// A bot cannot be allowed to wedge the table by choosing something the rules
|
||||||
|
// then refuse: it checks if it can and folds if it can't, and the mismatch
|
||||||
|
// is loud, because it means legal() and the betting rules disagree.
|
||||||
|
slog.Error("holdem: bot chose an illegal move", "seat", seat, "move", move.Kind, "err", err)
|
||||||
|
if s.Owed(seat) > 0 {
|
||||||
|
s.fold(seat, evs)
|
||||||
|
} else {
|
||||||
|
_ = s.check(seat, evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// potOdds is what a bot does when the trained table has never seen this spot: it
|
||||||
|
// works out whether the price it is being offered beats its chance of winning,
|
||||||
|
// and mixes in enough aggression not to be a calling station.
|
||||||
|
func potOdds(eq Equity, s *State, seat int) [numActions]float64 {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
strength := eq.Strength()
|
||||||
|
owed := s.Bet - p.Bet
|
||||||
|
pot := s.inPlay()
|
||||||
|
|
||||||
|
price := 0.0
|
||||||
|
if owed > 0 && pot+owed > 0 {
|
||||||
|
price = float64(owed) / float64(pot+owed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var probs [numActions]float64
|
||||||
|
switch {
|
||||||
|
case strength > 0.8:
|
||||||
|
probs[actRaisePot], probs[actAllIn], probs[actCallCheck] = 0.6, 0.2, 0.2
|
||||||
|
case strength > 0.6:
|
||||||
|
probs[actRaiseHalf], probs[actCallCheck], probs[actFold] = 0.4, 0.5, 0.1
|
||||||
|
case owed > 0 && strength > price:
|
||||||
|
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.7, 0.2, 0.1
|
||||||
|
case owed > 0:
|
||||||
|
probs[actFold], probs[actCallCheck] = 0.7, 0.3
|
||||||
|
default:
|
||||||
|
probs[actCallCheck], probs[actRaiseHalf], probs[actFold] = 0.6, 0.3, 0.1
|
||||||
|
}
|
||||||
|
return probs
|
||||||
|
}
|
||||||
|
|
||||||
|
// mask is what a seat may actually do here. The trainer explores exactly this
|
||||||
|
// set, so it never learns a strategy the table would turn down.
|
||||||
|
func (s State) mask(seat int) (m [numActions]bool) {
|
||||||
|
owed := s.Bet - s.Seats[seat].Bet
|
||||||
|
|
||||||
|
// Folding a hand you could see for free is a bug, not a strategy.
|
||||||
|
m[actFold] = owed > 0
|
||||||
|
m[actCallCheck] = true
|
||||||
|
|
||||||
|
// A raise needs chips behind the call, and somebody left to bet into.
|
||||||
|
raise := s.Seats[seat].Stack > owed && s.canBet()
|
||||||
|
m[actRaiseHalf], m[actRaisePot], m[actAllIn] = raise, raise, raise
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// legal zeroes out what the seat cannot do and renormalises what's left.
|
||||||
|
func legal(probs [numActions]float64, s *State, seat int) [numActions]float64 {
|
||||||
|
m := s.mask(seat)
|
||||||
|
var total float64
|
||||||
|
for i := range probs {
|
||||||
|
if !m[i] {
|
||||||
|
probs[i] = 0
|
||||||
|
}
|
||||||
|
total += probs[i]
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
var only [numActions]float64
|
||||||
|
only[actCallCheck] = 1
|
||||||
|
return only
|
||||||
|
}
|
||||||
|
for i := range probs {
|
||||||
|
probs[i] /= total
|
||||||
|
}
|
||||||
|
return probs
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick rolls against the distribution.
|
||||||
|
func pick(probs [numActions]float64, rng *rand.Rand) int {
|
||||||
|
r := rng.Float64()
|
||||||
|
sum := 0.0
|
||||||
|
for i, p := range probs {
|
||||||
|
sum += p
|
||||||
|
if r < sum {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actCallCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveFor turns an abstract action — "raise half the pot" — into a legal move at
|
||||||
|
// the actual size the table is at. A raise that would cost the bot everything it
|
||||||
|
// has is a shove, which is the same decision made honestly.
|
||||||
|
func (s *State) moveFor(action, seat int) Move {
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
owed := s.Bet - p.Bet
|
||||||
|
pot := s.inPlay()
|
||||||
|
most := p.Bet + p.Stack
|
||||||
|
|
||||||
|
sized := func(by int64) Move {
|
||||||
|
if by < s.MinRaise {
|
||||||
|
by = s.MinRaise
|
||||||
|
}
|
||||||
|
to := s.Bet + by
|
||||||
|
if to >= most {
|
||||||
|
return Move{Kind: Shove}
|
||||||
|
}
|
||||||
|
return Move{Kind: Raise, To: to}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case actFold:
|
||||||
|
if owed <= 0 {
|
||||||
|
return Move{Kind: Check} // never fold for free
|
||||||
|
}
|
||||||
|
return Move{Kind: Fold}
|
||||||
|
case actCallCheck:
|
||||||
|
if owed > 0 {
|
||||||
|
return Move{Kind: Call}
|
||||||
|
}
|
||||||
|
return Move{Kind: Check}
|
||||||
|
case actRaiseHalf:
|
||||||
|
return sized(pot / 2)
|
||||||
|
case actRaisePot:
|
||||||
|
return sized(pot)
|
||||||
|
case actAllIn:
|
||||||
|
return Move{Kind: Shove}
|
||||||
|
}
|
||||||
|
return Move{Kind: Check}
|
||||||
|
}
|
||||||
134
internal/games/holdem/equity.go
Normal file
134
internal/games/holdem/equity.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How good is this hand, really?
|
||||||
|
//
|
||||||
|
// A bot's decision starts here: deal the cards it cannot see, a thousand times,
|
||||||
|
// and count how often it wins. That number — plus the board's texture, the
|
||||||
|
// stack-to-pot ratio and the action so far — is the key it looks its trained
|
||||||
|
// strategy up under.
|
||||||
|
//
|
||||||
|
// Monte Carlo rather than exhaustive because exhaustive is 2.1 million river
|
||||||
|
// runouts against one opponent and rather more against five, and a thousand
|
||||||
|
// samples puts the estimate inside a percentage point or so. The bot does not
|
||||||
|
// need the fourth decimal place; it needs to know whether it is ahead.
|
||||||
|
|
||||||
|
// Equity is the fraction of runouts a hand wins, ties and loses against the
|
||||||
|
// given number of unknown opponents.
|
||||||
|
type Equity struct {
|
||||||
|
Win float64
|
||||||
|
Tie float64
|
||||||
|
Loss float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strength collapses a result into the one number the policy is keyed on: a tie
|
||||||
|
// is worth half a win, because that is what half a pot is.
|
||||||
|
func (e Equity) Strength() float64 { return e.Win + e.Tie*0.5 }
|
||||||
|
|
||||||
|
// deck52 is the evaluator's whole deck, built once.
|
||||||
|
var deck52 = func() []poker.Card {
|
||||||
|
d := make([]poker.Card, 0, 52)
|
||||||
|
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||||
|
for r := cards.Ace; r <= cards.King; r++ {
|
||||||
|
d = append(d, pokerOf[s][r])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
// equityOf runs the simulation. The RNG is threaded like everything else here,
|
||||||
|
// so a bot's decision replays from the session's seed along with the deal.
|
||||||
|
func equityOf(hole [2]cards.Card, board []cards.Card, opponents, iterations int, rng *rand.Rand) Equity {
|
||||||
|
if opponents < 1 {
|
||||||
|
opponents = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
h0, h1 := toPoker(hole[0]), toPoker(hole[1])
|
||||||
|
|
||||||
|
// Seven cards, checked by hand. A map here would be the most expensive thing
|
||||||
|
// in the trainer, which calls this function millions of times.
|
||||||
|
var known [7]poker.Card
|
||||||
|
n := 2
|
||||||
|
known[0], known[1] = h0, h1
|
||||||
|
pb := make([]poker.Card, len(board))
|
||||||
|
for i, c := range board {
|
||||||
|
pb[i] = toPoker(c)
|
||||||
|
known[n] = pb[i]
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := make([]poker.Card, 0, 52)
|
||||||
|
for _, c := range deck52 {
|
||||||
|
seen := false
|
||||||
|
for _, k := range known[:n] {
|
||||||
|
if k == c {
|
||||||
|
seen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
rest = append(rest, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
need := opponents*2 + (5 - len(pb))
|
||||||
|
if need > len(rest) {
|
||||||
|
return Equity{Tie: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
hero := make([]poker.Card, 7)
|
||||||
|
hero[0], hero[1] = h0, h1
|
||||||
|
villain := make([]poker.Card, 7)
|
||||||
|
full := make([]poker.Card, 5)
|
||||||
|
|
||||||
|
var wins, ties int
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
// A partial Fisher-Yates: only the cards actually needed get shuffled into
|
||||||
|
// place, which is the difference between this being cheap and being the
|
||||||
|
// slowest thing in the request.
|
||||||
|
for j := 0; j < need; j++ {
|
||||||
|
k := j + rng.IntN(len(rest)-j)
|
||||||
|
rest[j], rest[k] = rest[k], rest[j]
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(full, pb)
|
||||||
|
at := opponents * 2
|
||||||
|
for b := len(pb); b < 5; b++ {
|
||||||
|
full[b] = rest[at]
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(hero[2:], full)
|
||||||
|
mine := poker.Evaluate(hero)
|
||||||
|
|
||||||
|
best := int32(7463) // one worse than the worst real hand
|
||||||
|
for o := 0; o < opponents; o++ {
|
||||||
|
villain[0], villain[1] = rest[o*2], rest[o*2+1]
|
||||||
|
copy(villain[2:], full)
|
||||||
|
if r := poker.Evaluate(villain); r < best {
|
||||||
|
best = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case mine < best:
|
||||||
|
wins++
|
||||||
|
case mine == best:
|
||||||
|
ties++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := float64(iterations)
|
||||||
|
return Equity{
|
||||||
|
Win: float64(wins) / total,
|
||||||
|
Tie: float64(ties) / total,
|
||||||
|
Loss: float64(iterations-wins-ties) / total,
|
||||||
|
}
|
||||||
|
}
|
||||||
292
internal/games/holdem/eval.go
Normal file
292
internal/games/holdem/eval.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bridge to the evaluator.
|
||||||
|
//
|
||||||
|
// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and
|
||||||
|
// the felt already speak — and converts at the door. Hand strength is the one
|
||||||
|
// thing in this package that is genuinely hard to get right (7-card best-of-5,
|
||||||
|
// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is
|
||||||
|
// a lookup table and it is correct.
|
||||||
|
//
|
||||||
|
// The conversion is a table built once. Doing it per evaluation would matter:
|
||||||
|
// a bot's equity estimate is a thousand seven-card evaluations, and it makes
|
||||||
|
// several of those per hand.
|
||||||
|
|
||||||
|
var (
|
||||||
|
pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
|
||||||
|
pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs
|
||||||
|
|
||||||
|
// A var initializer, not an init(). Go builds package-level variables before
|
||||||
|
// it runs init functions, so anything else in this package that is itself a
|
||||||
|
// var built out of this table — equity.go's deck52 is — would otherwise be
|
||||||
|
// built out of an empty one. It was, briefly: every card came out identical,
|
||||||
|
// every showdown tied, and every bot believed it held exactly 50% equity.
|
||||||
|
pokerOf = func() (t [4][14]poker.Card) {
|
||||||
|
for s := cards.Spades; s <= cards.Clubs; s++ {
|
||||||
|
for r := cards.Ace; r <= cards.King; r++ {
|
||||||
|
t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
|
||||||
|
// toPoker converts one card for the evaluator.
|
||||||
|
func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] }
|
||||||
|
|
||||||
|
func toPokerAll(cs []cards.Card) []poker.Card {
|
||||||
|
out := make([]poker.Card, len(cs))
|
||||||
|
for i, c := range cs {
|
||||||
|
out[i] = toPoker(c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankOf evaluates a seat's best five from its hole cards and the board. Lower
|
||||||
|
// is better — 1 is a royal flush — which is the evaluator's convention and not
|
||||||
|
// worth inverting, since nothing outside this file ever sees the number.
|
||||||
|
func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) {
|
||||||
|
seven := make([]poker.Card, 0, 7)
|
||||||
|
seven = append(seven, toPoker(hole[0]), toPoker(hole[1]))
|
||||||
|
seven = append(seven, toPokerAll(board)...)
|
||||||
|
r := poker.Evaluate(seven)
|
||||||
|
return r, strings.ToLower(poker.RankString(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- showdown -------------------------------------------------------------
|
||||||
|
|
||||||
|
type ranked struct {
|
||||||
|
seat int
|
||||||
|
rank int32
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// showdown turns the cards over, splits the pots, and pays. Every player still
|
||||||
|
// in the hand shows, in the order the felt should turn them over: best hand
|
||||||
|
// first, so the winner is the first card face the player sees.
|
||||||
|
func (s *State) showdown(evs *[]Event) {
|
||||||
|
s.collect()
|
||||||
|
s.Street = Showdown
|
||||||
|
|
||||||
|
// Cut the side pots, if nobody has cut them yet.
|
||||||
|
//
|
||||||
|
// runout() does this, but runout only happens when the betting stops because
|
||||||
|
// there is nobody left able to bet. A hand can reach a showdown with an all-in
|
||||||
|
// player in it and the betting having finished perfectly normally: a short stack
|
||||||
|
// shoves, and two players who both have chips behind keep betting past them,
|
||||||
|
// street after street, all the way to the river. Nothing has been cut, and the
|
||||||
|
// short stack is sitting in a single pot marked eligible for all of it.
|
||||||
|
//
|
||||||
|
// Which means they can win every chip the deep players put in *after* they were
|
||||||
|
// already all-in — money they could never have lost. All-in for 100 against two
|
||||||
|
// players who each put in 500, and the best hand takes 1,100 instead of the 300
|
||||||
|
// they were playing for. The chips still balance, so conservation says nothing;
|
||||||
|
// they just go to the wrong seat.
|
||||||
|
if len(s.Side) == 0 && s.anyAllIn() {
|
||||||
|
s.sidePots()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say so. The last street's bets are still sitting in front of the seats that
|
||||||
|
// made them, as far as the felt knows, and nothing else in the script is going
|
||||||
|
// to tell it they have been swept in.
|
||||||
|
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||||
|
|
||||||
|
var live []ranked
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
if p.State == Folded || p.State == Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, desc := rankOf(p.Hole, s.Community)
|
||||||
|
live = append(live, ranked{seat: i, rank: r, desc: desc})
|
||||||
|
}
|
||||||
|
sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank })
|
||||||
|
|
||||||
|
for _, e := range live {
|
||||||
|
*evs = append(*evs, Event{
|
||||||
|
Kind: "show", Seat: e.seat,
|
||||||
|
Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]},
|
||||||
|
Text: e.desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pots := s.Side
|
||||||
|
if len(pots) == 0 {
|
||||||
|
all := make([]int, 0, len(live))
|
||||||
|
for _, e := range live {
|
||||||
|
all = append(all, e.seat)
|
||||||
|
}
|
||||||
|
pots = []Pot{{Amount: s.Pot, Eligible: all}}
|
||||||
|
s.Pot = 0
|
||||||
|
}
|
||||||
|
s.Side = nil
|
||||||
|
|
||||||
|
for _, pot := range pots {
|
||||||
|
s.payPot(pot, live, evs)
|
||||||
|
}
|
||||||
|
s.endHand(evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// payPot rakes a pot and splits it between the best eligible hands.
|
||||||
|
//
|
||||||
|
// The rake comes out of the pot before it is split, which is what a cardroom
|
||||||
|
// does and is also the only thing consistent with the rest of this casino: a
|
||||||
|
// player pays it out of a pot they *win*, never out of a bet they lose. A hand
|
||||||
|
// that dies before the flop is not raked at all — no flop, no drop — so folding
|
||||||
|
// your blind round after round costs you exactly the blinds and no fee.
|
||||||
|
func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
||||||
|
if pot.Amount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible := make(map[int]bool, len(pot.Eligible))
|
||||||
|
for _, seat := range pot.Eligible {
|
||||||
|
eligible[seat] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var winners []ranked
|
||||||
|
best := int32(0)
|
||||||
|
for _, e := range live {
|
||||||
|
if !eligible[e.seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(winners) == 0 || e.rank < best {
|
||||||
|
best, winners = e.rank, []ranked{e}
|
||||||
|
} else if e.rank == best {
|
||||||
|
winners = append(winners, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(winners) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := pot.Amount
|
||||||
|
if s.Flopped {
|
||||||
|
rake := int64(math.Floor(float64(amount) * s.Tier.RakePct))
|
||||||
|
if cap := s.Tier.BB * rakeCapBB; rake > cap {
|
||||||
|
rake = cap
|
||||||
|
}
|
||||||
|
if rake > 0 {
|
||||||
|
amount -= rake
|
||||||
|
s.Rake += rake // every chip of it, so the table still balances
|
||||||
|
|
||||||
|
// But only the part that came out of *your* winnings is money the house
|
||||||
|
// actually made, and it is the only part the felt should quote you. The
|
||||||
|
// bots' chips are not real — the only real money at this table is yours —
|
||||||
|
// so raking a pot a bot won costs you nothing, and a counter that climbed
|
||||||
|
// while you folded would be telling you it had.
|
||||||
|
for _, w := range winners {
|
||||||
|
if w.seat == You {
|
||||||
|
s.Paid += rake / int64(len(winners))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
share := amount / int64(len(winners))
|
||||||
|
odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button
|
||||||
|
for i, w := range winners {
|
||||||
|
won := share
|
||||||
|
if i == 0 {
|
||||||
|
won += odd
|
||||||
|
}
|
||||||
|
s.Seats[w.seat].Stack += won
|
||||||
|
s.Seats[w.seat].Won += won
|
||||||
|
*evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeit ends a hand nobody contested: everyone else folded, so the last player
|
||||||
|
// standing takes the pot without showing. Their own uncalled bet comes back
|
||||||
|
// first — it was never called, so it was never really in the pot.
|
||||||
|
func (s *State) takeit(evs *[]Event) {
|
||||||
|
s.uncalled(evs)
|
||||||
|
s.collect()
|
||||||
|
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
||||||
|
|
||||||
|
winner := -1
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State != Folded && s.Seats[i].State != Out {
|
||||||
|
winner = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winner < 0 {
|
||||||
|
s.endHand(evs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are never side pots here: they are only cut once the betting is over
|
||||||
|
// because everybody is all-in, and a table where everybody is all-in is a table
|
||||||
|
// where nobody is left to fold.
|
||||||
|
pot := Pot{Amount: s.Pot, Eligible: []int{winner}}
|
||||||
|
s.Pot = 0
|
||||||
|
s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs)
|
||||||
|
s.endHand(evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uncalled returns the unmatched top of a bet. If you shove 500 into a player
|
||||||
|
// with 200 behind, 300 of that was never contested and comes straight back.
|
||||||
|
//
|
||||||
|
// It must run *before* the bets are swept into the pot, and the matched level it
|
||||||
|
// measures against counts the players who folded. Their chips are in the pot —
|
||||||
|
// they paid to see the bet and then gave up — so the money they put in is money
|
||||||
|
// that called. Miss that and a bet folded to on the river comes back whole,
|
||||||
|
// including the part that was called on the flop, which mints chips out of air.
|
||||||
|
//
|
||||||
|
// The rake is the other reason this matters at all. When everybody folds, the
|
||||||
|
// winner takes the pot back either way and the arithmetic looks the same — but a
|
||||||
|
// pot with an uncalled bet still in it is a pot the house rakes, and it would be
|
||||||
|
// raking the player on their own money that nobody ever contested.
|
||||||
|
func (s *State) uncalled(evs *[]Event) {
|
||||||
|
top, topSeat := int64(-1), -1
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
if p.State == Folded || p.State == Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Total > top {
|
||||||
|
top, topSeat = p.Total, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topSeat < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched int64 // the most anybody else put in, whether or not they're still in
|
||||||
|
for i := range s.Seats {
|
||||||
|
if i == topSeat || s.Seats[i].State == Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Seats[i].Total > matched {
|
||||||
|
matched = s.Seats[i].Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
excess := top - matched
|
||||||
|
p := &s.Seats[topSeat]
|
||||||
|
if excess <= 0 || excess > p.Bet {
|
||||||
|
// An uncalled bet is always part of the street it was made on, so it cannot
|
||||||
|
// be bigger than what that seat has in front of them right now.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Stack += excess
|
||||||
|
p.Total -= excess
|
||||||
|
p.Bet -= excess
|
||||||
|
if p.State == AllIn && p.Stack > 0 {
|
||||||
|
p.State = Active // they were never really all-in against anybody
|
||||||
|
}
|
||||||
|
*evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess})
|
||||||
|
}
|
||||||
869
internal/games/holdem/holdem.go
Normal file
869
internal/games/holdem/holdem.go
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
// Package holdem is a pure Texas Hold'em engine, played for chips against bots.
|
||||||
|
//
|
||||||
|
// Same seam as every other table in the casino: ApplyMove(state, move) (state,
|
||||||
|
// events, error), where an error means the move was illegal and nothing else.
|
||||||
|
// No HTTP, no timers, no sockets. The state is a plain value, so a hand survives
|
||||||
|
// a redeploy and replays from its seed.
|
||||||
|
//
|
||||||
|
// Three things make hold'em different from the five tables already on the felt.
|
||||||
|
//
|
||||||
|
// It is a cash game, not a stake. Every other game here takes a bet, plays once
|
||||||
|
// and pays a multiple. Poker isn't that: you buy chips onto the table, you play
|
||||||
|
// as many hands as you like, and you leave with whatever is in front of you. So
|
||||||
|
// the session is the unit, not the hand — the live row lives across hands, the
|
||||||
|
// buy-in is the only chip movement at the start, and the stack going home is the
|
||||||
|
// only one at the end. In between the money is entirely inside this engine.
|
||||||
|
//
|
||||||
|
// The bots move inside ApplyMove, as they do in UNO. One call plays the player's
|
||||||
|
// action and every bot action behind it, deals whatever streets that completes,
|
||||||
|
// and hands the lot back as a script of events. Poker is where you would reach
|
||||||
|
// for a socket, and this is what not reaching for one costs.
|
||||||
|
//
|
||||||
|
// The bots are the trained ones. gogobee spent a long time running CFR against
|
||||||
|
// this game and the policy it converged on is the best asset in either repo; it
|
||||||
|
// is embedded here whole (see cfr.go). What that means for the player: the house
|
||||||
|
// edge at this table is not a rule, it is an opponent. There is no 3:2 and no
|
||||||
|
// multiple. If you beat the bots you win, and the only thing the house takes is
|
||||||
|
// the rake on the pots you win.
|
||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an illegal move can produce. An error means nothing happened.
|
||||||
|
var (
|
||||||
|
ErrOver = errors.New("holdem: you've left the table")
|
||||||
|
ErrNotYourTurn = errors.New("holdem: it isn't your turn")
|
||||||
|
ErrHandLive = errors.New("holdem: finish the hand first")
|
||||||
|
ErrNoHand = errors.New("holdem: no hand in progress")
|
||||||
|
ErrCantCheck = errors.New("holdem: there's a bet to you")
|
||||||
|
ErrNothingToCall = errors.New("holdem: nothing to call")
|
||||||
|
ErrTooSmall = errors.New("holdem: that's under the minimum raise")
|
||||||
|
ErrTooBig = errors.New("holdem: you don't have that many chips")
|
||||||
|
ErrNoChips = errors.New("holdem: you have no chips left")
|
||||||
|
ErrUnknownMove = errors.New("holdem: unknown move")
|
||||||
|
ErrUnknownTier = errors.New("holdem: no such table")
|
||||||
|
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
|
||||||
|
ErrTableFull = errors.New("holdem: too many seats")
|
||||||
|
)
|
||||||
|
|
||||||
|
// You are always seat zero. The bots are the seats after you.
|
||||||
|
const You = 0
|
||||||
|
|
||||||
|
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
|
||||||
|
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
|
||||||
|
// take off a player for winning it.
|
||||||
|
const rakeCapBB = 3
|
||||||
|
|
||||||
|
// Street is how far the board has come.
|
||||||
|
type Street uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
PreFlop Street = iota
|
||||||
|
Flop
|
||||||
|
Turn
|
||||||
|
River
|
||||||
|
Showdown
|
||||||
|
)
|
||||||
|
|
||||||
|
var streetNames = [5]string{"preflop", "flop", "turn", "river", "showdown"}
|
||||||
|
|
||||||
|
func (s Street) String() string {
|
||||||
|
if int(s) >= len(streetNames) {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return streetNames[s]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatState is where a player stands in the hand being played.
|
||||||
|
type SeatState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Active SeatState = iota // still has chips and a say
|
||||||
|
Folded // out of this hand
|
||||||
|
AllIn // in the hand, but has nothing left to bet
|
||||||
|
Out // not dealt in at all
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is one player at the table — you, or one of the bots.
|
||||||
|
//
|
||||||
|
// Hole is on the server and stays there. The view layer sends your two cards to
|
||||||
|
// you and sends nobody else's to anybody, right up until a showdown turns them
|
||||||
|
// over. A bot's cards are most of the information in this game; a browser that
|
||||||
|
// held them would make counting cards a matter of reading the network tab.
|
||||||
|
type Seat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bot bool `json:"bot"`
|
||||||
|
Stack int64 `json:"stack"`
|
||||||
|
Hole [2]cards.Card `json:"hole"`
|
||||||
|
Bet int64 `json:"bet"` // put in on this street
|
||||||
|
Total int64 `json:"total"` // put in across this hand
|
||||||
|
Won int64 `json:"won"` // taken out of the pot this hand
|
||||||
|
State SeatState `json:"state"`
|
||||||
|
Acted bool `json:"acted"` // has chosen to do something this street
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pot is a pot and the seats that may win it. A hand with no all-in has exactly
|
||||||
|
// one; every all-in at a distinct level adds another.
|
||||||
|
type Pot struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Eligible []int `json:"eligible"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier is a table you can sit at. The dial is the stakes, and the stakes are
|
||||||
|
// what make a chip mean something: at 25/50 a careless call costs more than a
|
||||||
|
// whole session at 1/2.
|
||||||
|
//
|
||||||
|
// The buy-in range is the standard 20 to 100 big blinds. Sitting down short is
|
||||||
|
// a real strategy (fewer decisions, less to lose) and sitting down deep is the
|
||||||
|
// other one, so the range is a choice and not a formality.
|
||||||
|
type Tier struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SB int64 `json:"sb"`
|
||||||
|
BB int64 `json:"bb"`
|
||||||
|
MinBuy int64 `json:"min_buy"`
|
||||||
|
MaxBuy int64 `json:"max_buy"`
|
||||||
|
RakePct float64 `json:"rake_pct"`
|
||||||
|
Blurb string `json:"blurb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers are the three tables. The rake is the casino's five percent everywhere,
|
||||||
|
// capped at three big blinds a pot, and taken only from a pot that saw a flop.
|
||||||
|
//
|
||||||
|
// RakePct is a *fraction*, 0.05, because that is what it is everywhere else in
|
||||||
|
// the casino — blackjack's DefaultRules says 0.05 and New() takes its word for it.
|
||||||
|
// It was 5 here for an afternoon, meaning percent, and since New overwrites the
|
||||||
|
// tier's value with the one it is handed, every rake worked out to five percent of
|
||||||
|
// a hundredth of the pot, which integer division rounded to nothing. The house took
|
||||||
|
// nothing at all and no test noticed, because every test set the tier up by hand.
|
||||||
|
var Tiers = []Tier{
|
||||||
|
{Slug: "micro", Name: "The Kitchen Table", SB: 1, BB: 2, MinBuy: 40, MaxBuy: 200, RakePct: 0.05,
|
||||||
|
Blurb: "1/2 blinds. Cheap enough to learn what the bots do to you."},
|
||||||
|
{Slug: "low", Name: "The Back Room", SB: 5, BB: 10, MinBuy: 200, MaxBuy: 1000, RakePct: 0.05,
|
||||||
|
Blurb: "5/10. A bluff here costs real chips, which is the only reason a bluff works."},
|
||||||
|
{Slug: "high", Name: "The High Roller", SB: 25, BB: 50, MinBuy: 1000, MaxBuy: 5000, RakePct: 0.05,
|
||||||
|
Blurb: "25/50. Three streets of this and you know whether you can play."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierBySlug finds a table by the name the browser sent.
|
||||||
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
|
for _, t := range Tiers {
|
||||||
|
if t.Slug == slug {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Tier{}, ErrUnknownTier
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxBots is five, which with you makes a six-handed table — the size most
|
||||||
|
// online poker is actually played at, and as many opponents as the felt can
|
||||||
|
// show without the cards getting too small to read.
|
||||||
|
const MaxBots = 5
|
||||||
|
|
||||||
|
// Phase is what the table is waiting for.
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseBetting Phase = "betting" // a hand is live and it's your turn
|
||||||
|
PhaseHandOver Phase = "handover" // the hand is paid; deal, top up, or leave
|
||||||
|
PhaseDone Phase = "done" // you're up from the table; the stack goes home
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the whole table. It never leaves the server: the deck is in here,
|
||||||
|
// and so is every bot's hand.
|
||||||
|
type State struct {
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
Seats []Seat `json:"seats"`
|
||||||
|
Button int `json:"button"`
|
||||||
|
HandNo int `json:"hand_no"`
|
||||||
|
|
||||||
|
Deck cards.Deck `json:"deck"`
|
||||||
|
Community []cards.Card `json:"community"`
|
||||||
|
Street Street `json:"street"`
|
||||||
|
Flopped bool `json:"flopped"` // this hand saw a flop, so its pot is rakeable
|
||||||
|
|
||||||
|
Pot int64 `json:"pot"`
|
||||||
|
Side []Pot `json:"side,omitempty"`
|
||||||
|
Bet int64 `json:"bet"` // the bet to match on this street
|
||||||
|
MinRaise int64 `json:"min_raise"` // the smallest legal raise over it
|
||||||
|
Aggressor int `json:"aggressor"`
|
||||||
|
ToAct int `json:"to_act"`
|
||||||
|
|
||||||
|
// History is the action so far on this street, as the f/c/r/R/a characters
|
||||||
|
// the CFR policy was trained to read. It is the bots' memory and nothing else.
|
||||||
|
History string `json:"history"`
|
||||||
|
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
|
||||||
|
// The money that crosses the border. BoughtIn is every chip staked onto this
|
||||||
|
// table; Payout is the stack that goes home, and is only set once you're up.
|
||||||
|
BoughtIn int64 `json:"bought_in"`
|
||||||
|
Payout int64 `json:"payout"`
|
||||||
|
|
||||||
|
// Two rakes, and they are different numbers.
|
||||||
|
//
|
||||||
|
// Rake is every chip the house has lifted off this table. It exists so the
|
||||||
|
// chips balance: a pot that is raked is a pot that pays out less than it holds,
|
||||||
|
// and the difference has to be somewhere.
|
||||||
|
//
|
||||||
|
// Paid is the part of that which came out of a pot *you* won — the only part
|
||||||
|
// that is real money, and the only part worth quoting you. Rake a pot a bot
|
||||||
|
// wins and you have paid nothing; a counter that climbed anyway while you sat
|
||||||
|
// folding would be lying to you.
|
||||||
|
Rake int64 `json:"rake"`
|
||||||
|
Paid int64 `json:"paid"`
|
||||||
|
|
||||||
|
// The seed rides in the state for the same reason it does in UNO: the bots
|
||||||
|
// choose and the deck is reshuffled every hand, so the engine needs randomness
|
||||||
|
// mid-session — and there is no generator alive between two HTTP requests to
|
||||||
|
// hand it. Each step derives its own from the seed and the step count, so the
|
||||||
|
// session still replays exactly as it fell.
|
||||||
|
Seed1 uint64 `json:"seed1"`
|
||||||
|
Seed2 uint64 `json:"seed2"`
|
||||||
|
Step uint64 `json:"step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is one beat of the script the felt plays back. Seat is -1 when the beat
|
||||||
|
// belongs to the table rather than a player.
|
||||||
|
type Event struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Seat int `json:"seat"`
|
||||||
|
Cards []cards.Card `json:"cards,omitempty"`
|
||||||
|
Amount int64 `json:"amount,omitempty"`
|
||||||
|
Total int64 `json:"total,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The moves a player can make. The betting five, plus the three that are about
|
||||||
|
// the session rather than the hand.
|
||||||
|
const (
|
||||||
|
Fold = "fold"
|
||||||
|
Check = "check"
|
||||||
|
Call = "call"
|
||||||
|
Raise = "raise"
|
||||||
|
Shove = "allin" // the move; AllIn is the seat state it puts you in
|
||||||
|
|
||||||
|
Deal = "deal" // next hand
|
||||||
|
TopUp = "topup" // put more chips on the table, between hands
|
||||||
|
Leave = "leave" // get up; the stack goes back to your stack
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move is what the browser sends. To is the total a raise raises *to*, and
|
||||||
|
// Amount is chips added in a top-up.
|
||||||
|
type Move struct {
|
||||||
|
Kind string `json:"move"`
|
||||||
|
To int64 `json:"to,omitempty"`
|
||||||
|
Amount int64 `json:"amount,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// botNames are the regulars. Six of them so a full table never has two.
|
||||||
|
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
|
||||||
|
|
||||||
|
// New sits you down. The buy-in is chips the caller has already taken off the
|
||||||
|
// player's stack; this engine only ever gives them back through Leave.
|
||||||
|
//
|
||||||
|
// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a
|
||||||
|
// table between hands is in, and the first Deal move starts the first hand.
|
||||||
|
func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
||||||
|
if bots < 1 || bots > MaxBots {
|
||||||
|
return State{}, nil, ErrTableFull
|
||||||
|
}
|
||||||
|
if buyIn < t.MinBuy || buyIn > t.MaxBuy {
|
||||||
|
return State{}, nil, ErrBadBuyIn
|
||||||
|
}
|
||||||
|
t.RakePct = rakePct
|
||||||
|
|
||||||
|
s := State{
|
||||||
|
Tier: t,
|
||||||
|
Button: 0,
|
||||||
|
Phase: PhaseHandOver,
|
||||||
|
BoughtIn: buyIn,
|
||||||
|
Seed1: seed1,
|
||||||
|
Seed2: seed2,
|
||||||
|
}
|
||||||
|
s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn})
|
||||||
|
for i := 0; i < bots; i++ {
|
||||||
|
s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The button starts to your right, so the first hand deals you the small blind
|
||||||
|
// heads-up and the button on a full table — either way you are in the action
|
||||||
|
// from the first card rather than folding your way in.
|
||||||
|
s.Button = len(s.Seats) - 1
|
||||||
|
|
||||||
|
evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}}
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove is the whole engine. It plays your move, then every bot behind you,
|
||||||
|
// deals whatever streets that finishes, and stops either when it is your turn
|
||||||
|
// again or when the hand is over.
|
||||||
|
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||||
|
if s.Phase == PhaseDone {
|
||||||
|
return s, nil, ErrOver
|
||||||
|
}
|
||||||
|
rng := s.next()
|
||||||
|
evs := []Event{}
|
||||||
|
|
||||||
|
switch m.Kind {
|
||||||
|
case Deal:
|
||||||
|
if s.Phase != PhaseHandOver {
|
||||||
|
return s, nil, ErrHandLive
|
||||||
|
}
|
||||||
|
s.deal(&evs, rng)
|
||||||
|
s.advance(&evs, rng, true)
|
||||||
|
|
||||||
|
case TopUp:
|
||||||
|
if s.Phase != PhaseHandOver {
|
||||||
|
return s, nil, ErrHandLive
|
||||||
|
}
|
||||||
|
if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy {
|
||||||
|
return s, nil, ErrBadBuyIn
|
||||||
|
}
|
||||||
|
s.Seats[You].Stack += m.Amount
|
||||||
|
s.BoughtIn += m.Amount
|
||||||
|
evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount})
|
||||||
|
|
||||||
|
case Leave:
|
||||||
|
if s.Phase != PhaseHandOver {
|
||||||
|
return s, nil, ErrHandLive
|
||||||
|
}
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
s.Payout = s.Seats[You].Stack
|
||||||
|
evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout})
|
||||||
|
|
||||||
|
case Fold, Check, Call, Raise, Shove:
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
return s, nil, ErrNoHand
|
||||||
|
}
|
||||||
|
if s.ToAct != You {
|
||||||
|
return s, nil, ErrNotYourTurn
|
||||||
|
}
|
||||||
|
if err := s.act(You, m, &evs); err != nil {
|
||||||
|
return s, nil, err // nothing happened; the caller keeps the old state
|
||||||
|
}
|
||||||
|
s.ToAct = s.nextCanAct(You)
|
||||||
|
s.advance(&evs, rng, true)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return s, nil, ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// step plays a move for whoever is to act — bot or player — and advances only as
|
||||||
|
// far as the next decision, whoever's it is. Nobody's turn is taken for them.
|
||||||
|
//
|
||||||
|
// This is the seam the trainer plays through, and it exists so that the trainer
|
||||||
|
// is playing *this* game: the same betting rules, the same street completion, the
|
||||||
|
// same side pots, the same money. The alternative is a second, simplified model
|
||||||
|
// of poker written for the trainer alone — which is what gogobee had, and it is
|
||||||
|
// why its policy encoded a game nobody was dealing.
|
||||||
|
func step(s State, m Move) (State, []Event, error) {
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
return s, nil, ErrNoHand
|
||||||
|
}
|
||||||
|
rng := s.next()
|
||||||
|
evs := []Event{}
|
||||||
|
|
||||||
|
seat := s.ToAct
|
||||||
|
if err := s.act(seat, m, &evs); err != nil {
|
||||||
|
return s, nil, err
|
||||||
|
}
|
||||||
|
s.ToAct = s.nextCanAct(seat)
|
||||||
|
s.advance(&evs, rng, false)
|
||||||
|
return s, evs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open deals one heads-up hand at the given stacks, stopping at the first
|
||||||
|
// decision. For the trainer: a table with no history and no button rotation.
|
||||||
|
func open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
|
||||||
|
if stack0 <= 0 || stack1 <= 0 {
|
||||||
|
return State{}, ErrBadBuyIn
|
||||||
|
}
|
||||||
|
s := State{
|
||||||
|
Tier: t,
|
||||||
|
Phase: PhaseHandOver,
|
||||||
|
Seats: []Seat{
|
||||||
|
{Name: "0", Stack: stack0},
|
||||||
|
{Name: "1", Stack: stack1, Bot: true},
|
||||||
|
},
|
||||||
|
Button: 1, // deal() moves it, so seat 0 takes the button and the small blind
|
||||||
|
Seed1: seed1,
|
||||||
|
Seed2: seed2,
|
||||||
|
}
|
||||||
|
rng := s.next()
|
||||||
|
evs := []Event{}
|
||||||
|
s.deal(&evs, rng)
|
||||||
|
s.advance(&evs, rng, false)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy
|
||||||
|
// would have every branch writing into the same deck.
|
||||||
|
func (s State) clone() State {
|
||||||
|
out := s
|
||||||
|
out.Seats = append([]Seat(nil), s.Seats...)
|
||||||
|
out.Deck = append(cards.Deck(nil), s.Deck...)
|
||||||
|
out.Community = append([]cards.Card(nil), s.Community...)
|
||||||
|
out.Side = make([]Pot, len(s.Side))
|
||||||
|
for i, p := range s.Side {
|
||||||
|
out.Side[i] = Pot{Amount: p.Amount, Eligible: append([]int(nil), p.Eligible...)}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// act applies one seat's action, whoever it belongs to.
|
||||||
|
func (s *State) act(seat int, m Move, evs *[]Event) error {
|
||||||
|
switch m.Kind {
|
||||||
|
case Fold:
|
||||||
|
s.fold(seat, evs)
|
||||||
|
return nil
|
||||||
|
case Check:
|
||||||
|
return s.check(seat, evs)
|
||||||
|
case Call:
|
||||||
|
return s.call(seat, evs)
|
||||||
|
case Raise:
|
||||||
|
return s.raise(seat, m.To, evs)
|
||||||
|
case Shove:
|
||||||
|
return s.allin(seat, evs)
|
||||||
|
}
|
||||||
|
return ErrUnknownMove
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance runs the table forward until you have a decision to make, or until
|
||||||
|
// there is nothing left to decide.
|
||||||
|
//
|
||||||
|
// This is the loop the whole design turns on. Every other engine here returns
|
||||||
|
// after one move because there is nobody else at the table; this one keeps going
|
||||||
|
// — bot, bot, flop, bot, turn — and only stops when the answer has to come from
|
||||||
|
// the player. Which is why one HTTP request can be a whole hand: shove all-in
|
||||||
|
// and the board runs out and the pot is paid inside a single call.
|
||||||
|
//
|
||||||
|
// With bots false it stops at every decision instead of playing the bots' for
|
||||||
|
// them. That is the trainer's way in: it wants to choose both seats' moves.
|
||||||
|
func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
|
||||||
|
for {
|
||||||
|
// Everyone else folded. Nobody shows; the last one standing takes it.
|
||||||
|
if s.liveCount() <= 1 {
|
||||||
|
s.takeit(evs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody left with a decision to make: the rest of the board is a formality,
|
||||||
|
// so deal it and turn the cards over.
|
||||||
|
//
|
||||||
|
// The subtle half is the lone player who still has chips. They only have a
|
||||||
|
// decision if there is a bet to them — call it or fold. If there isn't, they
|
||||||
|
// have nobody left to bet *into*, because everyone else is already all-in,
|
||||||
|
// and poker does not let you put chips in a pot nobody can contest.
|
||||||
|
switch s.canActCount() {
|
||||||
|
case 0:
|
||||||
|
s.runout(evs)
|
||||||
|
return
|
||||||
|
case 1:
|
||||||
|
lone := s.onlyActor()
|
||||||
|
if s.Owed(lone) == 0 {
|
||||||
|
s.runout(evs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ToAct = lone
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.streetDone(s.ToAct) {
|
||||||
|
if s.Street == River {
|
||||||
|
s.showdown(evs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.street(evs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ToAct == You || !bots {
|
||||||
|
return // a decision that isn't ours to make
|
||||||
|
}
|
||||||
|
|
||||||
|
s.botActs(s.ToAct, evs, rng)
|
||||||
|
s.ToAct = s.nextCanAct(s.ToAct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deal starts a hand: rebuy the broke bots, move the button, shuffle, post the
|
||||||
|
// blinds, and put two cards in front of everybody.
|
||||||
|
func (s *State) deal(evs *[]Event, rng *rand.Rand) {
|
||||||
|
s.HandNo++
|
||||||
|
|
||||||
|
for i := range s.Seats {
|
||||||
|
p := &s.Seats[i]
|
||||||
|
// A bot that has been ground down to nothing reloads. It has to: a table
|
||||||
|
// where you have taken everybody's chips is a table with no game left in it,
|
||||||
|
// and their chips were never real anyway — the only real money at this table
|
||||||
|
// is yours, and the only thing the house takes is the rake.
|
||||||
|
if p.Bot && p.Stack < s.Tier.BB {
|
||||||
|
add := s.Tier.MaxBuy - p.Stack
|
||||||
|
p.Stack = s.Tier.MaxBuy
|
||||||
|
*evs = append(*evs, Event{Kind: "rebuy", Seat: i, Amount: add, Total: p.Stack})
|
||||||
|
}
|
||||||
|
p.Bet, p.Total, p.Won, p.Acted = 0, 0, 0, false
|
||||||
|
p.Hole = [2]cards.Card{}
|
||||||
|
p.State = Active
|
||||||
|
if p.Stack <= 0 {
|
||||||
|
p.State = Out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Community = nil
|
||||||
|
s.Side = nil
|
||||||
|
s.Pot = 0
|
||||||
|
s.Street = PreFlop
|
||||||
|
s.Flopped = false
|
||||||
|
s.History = ""
|
||||||
|
s.Phase = PhaseBetting
|
||||||
|
|
||||||
|
s.Deck = cards.NewDeck(1)
|
||||||
|
s.Deck.Shuffle(rng)
|
||||||
|
|
||||||
|
s.Button = s.nextIn(s.Button)
|
||||||
|
*evs = append(*evs, Event{Kind: "hand", Seat: s.Button, Amount: int64(s.HandNo)})
|
||||||
|
|
||||||
|
bb := s.blinds(evs)
|
||||||
|
|
||||||
|
// Two cards each, one at a time round the table, as they are actually dealt.
|
||||||
|
for round := 0; round < 2; round++ {
|
||||||
|
for i := 0; i < len(s.Seats); i++ {
|
||||||
|
seat := (s.Button + 1 + i) % len(s.Seats)
|
||||||
|
p := &s.Seats[seat]
|
||||||
|
if p.State == Out {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, _ := s.Deck.Draw()
|
||||||
|
p.Hole[round] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only your cards go into the script. The bots' are in the state, on this side
|
||||||
|
// of the wire, and the only thing that ever turns them over is a showdown.
|
||||||
|
*evs = append(*evs, Event{Kind: "hole", Seat: You,
|
||||||
|
Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}})
|
||||||
|
|
||||||
|
s.ToAct = s.firstPreFlop(bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// street burns one and deals the next board card or three.
|
||||||
|
func (s *State) street(evs *[]Event) {
|
||||||
|
s.collect()
|
||||||
|
s.resetBets()
|
||||||
|
|
||||||
|
s.Deck.Draw() // the burn card, as printed in the rules and as dealt in a casino
|
||||||
|
|
||||||
|
switch s.Street {
|
||||||
|
case PreFlop:
|
||||||
|
s.Street = Flop
|
||||||
|
s.Flopped = true
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
c, _ := s.Deck.Draw()
|
||||||
|
s.Community = append(s.Community, c)
|
||||||
|
}
|
||||||
|
case Flop:
|
||||||
|
s.Street = Turn
|
||||||
|
c, _ := s.Deck.Draw()
|
||||||
|
s.Community = append(s.Community, c)
|
||||||
|
case Turn:
|
||||||
|
s.Street = River
|
||||||
|
c, _ := s.Deck.Draw()
|
||||||
|
s.Community = append(s.Community, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total, not Pot: by the time a board runs out behind an all-in the money has
|
||||||
|
// already been cut into side pots, and s.Pot is zero.
|
||||||
|
*evs = append(*evs, Event{Kind: s.Street.String(), Seat: -1,
|
||||||
|
Cards: s.Community[len(s.Community)-cardsOn(s.Street):], Amount: s.Total()})
|
||||||
|
|
||||||
|
s.ToAct = s.firstPostFlop()
|
||||||
|
s.Aggressor = s.ToAct // nobody has bet yet, so the option ends where it starts
|
||||||
|
}
|
||||||
|
|
||||||
|
func cardsOn(st Street) int {
|
||||||
|
if st == Flop {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// runout deals the rest of the board with no more betting, because there is no
|
||||||
|
// longer anybody able to bet. The side pots are built first: once the chips stop
|
||||||
|
// moving, who can win what is already decided.
|
||||||
|
func (s *State) runout(evs *[]Event) {
|
||||||
|
allIn := false
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == AllIn {
|
||||||
|
allIn = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allIn {
|
||||||
|
// A shove nobody could cover comes back first — while it is still a bet in
|
||||||
|
// front of a seat, and before the pots are cut around it.
|
||||||
|
s.uncalled(evs)
|
||||||
|
}
|
||||||
|
s.collect()
|
||||||
|
if allIn {
|
||||||
|
s.sidePots()
|
||||||
|
}
|
||||||
|
|
||||||
|
for s.Street < River {
|
||||||
|
s.street(evs)
|
||||||
|
}
|
||||||
|
s.showdown(evs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endHand pays out and parks the table between hands.
|
||||||
|
func (s *State) endHand(evs *[]Event) {
|
||||||
|
s.Pot = 0
|
||||||
|
s.Side = nil
|
||||||
|
s.Phase = PhaseHandOver
|
||||||
|
*evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack})
|
||||||
|
|
||||||
|
// Busting is the end of the session, not the end of a hand. There is nothing
|
||||||
|
// to deal you and nothing to give back, so the table closes and you sit down
|
||||||
|
// again — which is a buy-in, and a buy-in is a decision worth making on purpose.
|
||||||
|
if s.Seats[You].Stack <= 0 {
|
||||||
|
s.Phase = PhaseDone
|
||||||
|
s.Payout = 0
|
||||||
|
*evs = append(*evs, Event{Kind: "bust", Seat: You})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the small stuff -------------------------------------------------------
|
||||||
|
|
||||||
|
// next derives this step's generator and advances the step count.
|
||||||
|
func (s *State) next() *rand.Rand {
|
||||||
|
s.Step++
|
||||||
|
return cards.NewRNG(s.Seed1, s.Seed2^s.Step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect sweeps the street's bets into the pot.
|
||||||
|
func (s *State) collect() {
|
||||||
|
for i := range s.Seats {
|
||||||
|
s.Pot += s.Seats[i].Bet
|
||||||
|
s.Seats[i].Bet = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetBets opens a new street: nothing to call, and nobody has spoken.
|
||||||
|
func (s *State) resetBets() {
|
||||||
|
for i := range s.Seats {
|
||||||
|
s.Seats[i].Bet = 0
|
||||||
|
s.Seats[i].Acted = false
|
||||||
|
}
|
||||||
|
s.Bet = 0
|
||||||
|
s.MinRaise = s.Tier.BB
|
||||||
|
s.History = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// inPlay is the pot plus everything bet on this street — what a bot is actually
|
||||||
|
// deciding against, and what a pot-sized raise is a size of.
|
||||||
|
func (s State) inPlay() int64 {
|
||||||
|
total := s.Pot
|
||||||
|
for i := range s.Seats {
|
||||||
|
total += s.Seats[i].Bet
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pot returns the money on the table, however it is currently sliced.
|
||||||
|
func (s State) Total() int64 {
|
||||||
|
total := s.inPlay()
|
||||||
|
for _, p := range s.Side {
|
||||||
|
total += p.Amount
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveCount is the seats still in the hand, whether or not they can bet.
|
||||||
|
func (s State) liveCount() int {
|
||||||
|
n := 0
|
||||||
|
for i := range s.Seats {
|
||||||
|
if st := s.Seats[i].State; st == Active || st == AllIn {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// canActCount is the seats that still have chips and a decision.
|
||||||
|
func (s State) canActCount() int {
|
||||||
|
n := 0
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == Active {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// dealt is the seats in this hand at all.
|
||||||
|
func (s State) dealt() int {
|
||||||
|
n := 0
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State != Out {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextIn is the next seat that was dealt in — used to move the button, which
|
||||||
|
// moves past a seat that is sitting out rather than landing on it.
|
||||||
|
func (s State) nextIn(from int) int {
|
||||||
|
n := len(s.Seats)
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
next := (from + i) % n
|
||||||
|
if st := s.Seats[next].State; st == Active || st == AllIn {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
|
||||||
|
func (s State) onlyActor() int {
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == Active {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.ToAct
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyAllIn reports whether anybody is in the hand with no chips left, which is
|
||||||
|
// the only thing that makes side pots necessary.
|
||||||
|
func (s State) anyAllIn() bool {
|
||||||
|
for i := range s.Seats {
|
||||||
|
if s.Seats[i].State == AllIn {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet reports whether there is anybody left to bet *into*. With one player
|
||||||
|
// still holding chips and the rest all-in, a raise is chips nobody can call, so
|
||||||
|
// no raise is offered — to the player or to a bot.
|
||||||
|
func (s State) canBet() bool { return s.canActCount() > 1 }
|
||||||
|
|
||||||
|
// nextCanAct is the next seat with a decision to make.
|
||||||
|
func (s State) nextCanAct(from int) int {
|
||||||
|
n := len(s.Seats)
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
next := (from + i) % n
|
||||||
|
if s.Seats[next].State == Active {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owed is what the seat must put in to call.
|
||||||
|
func (s State) Owed(seat int) int64 {
|
||||||
|
owed := s.Bet - s.Seats[seat].Bet
|
||||||
|
if owed < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if owed > s.Seats[seat].Stack {
|
||||||
|
return s.Seats[seat].Stack
|
||||||
|
}
|
||||||
|
return owed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinRaiseTo is the smallest total a raise may raise to, clamped to a shove when
|
||||||
|
// the seat cannot cover a full one.
|
||||||
|
func (s State) MinRaiseTo(seat int) int64 {
|
||||||
|
to := s.Bet + s.MinRaise
|
||||||
|
if most := s.Seats[seat].Bet + s.Seats[seat].Stack; to > most {
|
||||||
|
return most
|
||||||
|
}
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxRaiseTo is everything the seat has.
|
||||||
|
func (s State) MaxRaiseTo(seat int) int64 {
|
||||||
|
return s.Seats[seat].Bet + s.Seats[seat].Stack
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRaise reports whether the seat may raise: they need chips behind the call,
|
||||||
|
// and somebody left to bet into. The felt asks so it never offers a button the
|
||||||
|
// table would refuse.
|
||||||
|
func (s State) CanRaise(seat int) bool {
|
||||||
|
return s.mask(seat)[actRaiseHalf]
|
||||||
|
}
|
||||||
|
|
||||||
|
// InPosition reports whether the seat acts last after the flop, which is the
|
||||||
|
// only thing about position the trained bots actually know.
|
||||||
|
//
|
||||||
|
// The postflop order runs from the seat left of the button all the way round to
|
||||||
|
// the button itself, so the player in position is simply the last one still in
|
||||||
|
// the hand — the button, or whoever is nearest to it once the button has folded.
|
||||||
|
// The policy was trained heads-up, where this is exactly the button; applying it
|
||||||
|
// to a six-handed table is an approximation, and this is where the approximation
|
||||||
|
// lives.
|
||||||
|
func (s State) InPosition(seat int) bool {
|
||||||
|
last := -1
|
||||||
|
for i := 1; i <= len(s.Seats); i++ {
|
||||||
|
at := (s.Button + i) % len(s.Seats)
|
||||||
|
if st := s.Seats[at].State; st == Active || st == AllIn {
|
||||||
|
last = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seat == last
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position is the seat's label at this table — BTN, SB, BB, and so on. It is for
|
||||||
|
// the felt to print. The bots do not use it: see InPosition, and the note on
|
||||||
|
// infoSet about what happens when you confuse the two.
|
||||||
|
func (s State) Position(seat int) string {
|
||||||
|
n := s.dealt()
|
||||||
|
if n < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if seat == s.Button {
|
||||||
|
return "BTN"
|
||||||
|
}
|
||||||
|
if n == 2 {
|
||||||
|
return "BB" // heads-up, the other seat is always the big blind
|
||||||
|
}
|
||||||
|
|
||||||
|
sb := s.nextIn(s.Button)
|
||||||
|
bb := s.nextIn(sb)
|
||||||
|
switch seat {
|
||||||
|
case sb:
|
||||||
|
return "SB"
|
||||||
|
case bb:
|
||||||
|
return "BB"
|
||||||
|
}
|
||||||
|
|
||||||
|
utg := s.nextIn(bb)
|
||||||
|
if seat == utg {
|
||||||
|
return "UTG"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone between UTG and the button is somewhere in the middle; the seat
|
||||||
|
// closest to the button is the cutoff.
|
||||||
|
dist, cur := 0, utg
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
cur = s.nextIn(cur)
|
||||||
|
dist++
|
||||||
|
if cur == seat {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remaining := n - 4; remaining > 0 && dist >= remaining {
|
||||||
|
return "CO"
|
||||||
|
}
|
||||||
|
return "MP"
|
||||||
|
}
|
||||||
728
internal/games/holdem/holdem_test.go
Normal file
728
internal/games/holdem/holdem_test.go
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The one that matters: no chip is ever created or destroyed.
|
||||||
|
//
|
||||||
|
// Everything else in this package is a rule you could argue about. This is the
|
||||||
|
// one that would lose somebody money. Every chip at the table is in exactly one
|
||||||
|
// place — a stack, a bet in front of a seat, a pot, or the house's rake — and
|
||||||
|
// the only thing that ever adds to the total is a bot reloading. So: play a
|
||||||
|
// hundred sessions of real hands, with the trained bots making real decisions,
|
||||||
|
// and count the chips after every single move.
|
||||||
|
func TestChipsAreConserved(t *testing.T) {
|
||||||
|
for game := 0; game < 100; game++ {
|
||||||
|
rng := rand.New(rand.NewPCG(uint64(game), 99))
|
||||||
|
bots := 1 + game%MaxBots
|
||||||
|
tier := Tiers[game%len(Tiers)]
|
||||||
|
|
||||||
|
s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := chipsAt(s) // what the table started with
|
||||||
|
|
||||||
|
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
||||||
|
var evs []Event
|
||||||
|
s, evs, err = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||||
|
}
|
||||||
|
want += reloaded(evs) // a bot that rebought brought new chips with it
|
||||||
|
check(t, s, want, game, hand, "deal")
|
||||||
|
|
||||||
|
for step := 0; s.Phase == PhaseBetting; step++ {
|
||||||
|
if step > 200 {
|
||||||
|
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||||
|
}
|
||||||
|
s, _, err = ApplyMove(s, randomMove(s, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
||||||
|
}
|
||||||
|
check(t, s, want, game, hand, "move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chipsAt totals every chip the table can see, plus every one the house has
|
||||||
|
// already taken out of it.
|
||||||
|
func chipsAt(s State) int64 {
|
||||||
|
total := s.Rake + s.Pot
|
||||||
|
for _, p := range s.Seats {
|
||||||
|
total += p.Stack + p.Bet
|
||||||
|
}
|
||||||
|
for _, pot := range s.Side {
|
||||||
|
total += pot.Amount
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloaded is what the bots brought back to the table on this deal. It is the
|
||||||
|
// only thing in the game that is allowed to make chips out of nothing, which is
|
||||||
|
// exactly why the test has to know about it and nothing else does.
|
||||||
|
func reloaded(evs []Event) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "rebuy" {
|
||||||
|
n += e.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func check(t *testing.T, s State, want int64, game, hand int, when string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := chipsAt(s); got != want {
|
||||||
|
t.Fatalf("game %d hand %d, after %s: %d chips on the table, want %d "+
|
||||||
|
"(pot %d, rake %d, stacks %v)", game, hand, when, got, want, s.Pot, s.Rake, stacks(s))
|
||||||
|
}
|
||||||
|
for i, p := range s.Seats {
|
||||||
|
if p.Stack < 0 {
|
||||||
|
t.Fatalf("game %d hand %d: seat %d has a negative stack (%d)", game, hand, i, p.Stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stacks(s State) []int64 {
|
||||||
|
out := make([]int64, len(s.Seats))
|
||||||
|
for i, p := range s.Seats {
|
||||||
|
out[i] = p.Stack
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomMove picks something legal for the player, without any thought at all.
|
||||||
|
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
||||||
|
// short stacks and split pots far faster than a good one would.
|
||||||
|
func randomMove(s State, rng *rand.Rand) Move {
|
||||||
|
owed := s.Owed(You)
|
||||||
|
var legal []Move
|
||||||
|
if owed > 0 {
|
||||||
|
legal = append(legal, Move{Kind: Fold}, Move{Kind: Call})
|
||||||
|
} else {
|
||||||
|
legal = append(legal, Move{Kind: Check})
|
||||||
|
}
|
||||||
|
if s.Seats[You].Stack > owed && s.canBet() {
|
||||||
|
legal = append(legal, Move{Kind: Shove})
|
||||||
|
if to := s.MinRaiseTo(You); to < s.MaxRaiseTo(You) {
|
||||||
|
legal = append(legal, Move{Kind: Raise, To: to})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return legal[rng.IntN(len(legal))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rules a poker player would notice were wrong -----------------------
|
||||||
|
|
||||||
|
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 1, 200)
|
||||||
|
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The button posted the small blind, not the big one.
|
||||||
|
var small, big int
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == "blind" && e.Text == "small" {
|
||||||
|
small = e.Seat
|
||||||
|
}
|
||||||
|
if e.Kind == "blind" && e.Text == "big" {
|
||||||
|
big = e.Seat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if small != s.Button {
|
||||||
|
t.Errorf("heads-up: seat %d posted the small blind, but the button is seat %d", small, s.Button)
|
||||||
|
}
|
||||||
|
if big == s.Button {
|
||||||
|
t.Error("heads-up: the button posted the big blind too")
|
||||||
|
}
|
||||||
|
// And it is the first to act before the flop. (If the button is a bot it has
|
||||||
|
// already acted, so what we can check is that the player didn't get skipped.)
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
t.Fatalf("phase %q: the hand should be waiting on somebody", s.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
||||||
|
// A table where everyone just calls: the big blind has the bet matched without
|
||||||
|
// ever having chosen anything, and the street must not end until they speak.
|
||||||
|
s := table(t, Tiers[0], 1, 200)
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
|
||||||
|
// Find a hand where the player is the big blind. The button alternates, so at
|
||||||
|
// most a couple of deals.
|
||||||
|
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
||||||
|
s = playOut(t, s)
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
}
|
||||||
|
if s.Position(You) != "BB" {
|
||||||
|
t.Skip("never dealt the big blind")
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
return // the bot folded or raised; either way the option isn't the question
|
||||||
|
}
|
||||||
|
if s.ToAct == You && s.Owed(You) == 0 {
|
||||||
|
// This is the option: nothing to call, but the hand is still ours to act on.
|
||||||
|
if _, _, err := ApplyMove(s, Move{Kind: Check}); err != nil {
|
||||||
|
t.Errorf("the big blind cannot check their option: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShortAllInDoesNotReopenTheBetting(t *testing.T) {
|
||||||
|
s := State{
|
||||||
|
Tier: Tiers[1], // 5/10
|
||||||
|
Seats: []Seat{{Name: "You", Stack: 1000}, {Name: "Bot", Bot: true, Stack: 1000}},
|
||||||
|
Bet: 100,
|
||||||
|
MinRaise: 100, // a full raise would be to 200
|
||||||
|
Aggressor: You,
|
||||||
|
Phase: PhaseBetting,
|
||||||
|
}
|
||||||
|
s.Seats[You].Bet = 100
|
||||||
|
s.Seats[1].Bet = 0
|
||||||
|
s.Seats[1].Stack = 150 // can only get to 150, which is a raise of 50: not a full one
|
||||||
|
|
||||||
|
var evs []Event
|
||||||
|
if err := s.allin(1, &evs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Bet != 150 {
|
||||||
|
t.Errorf("the bet to call is %d, want 150", s.Bet)
|
||||||
|
}
|
||||||
|
if s.MinRaise != 100 {
|
||||||
|
t.Errorf("min raise moved to %d — a short all-in must not change it", s.MinRaise)
|
||||||
|
}
|
||||||
|
if s.Aggressor != You {
|
||||||
|
t.Errorf("the aggressor moved to seat %d — a short all-in must not reopen the action", s.Aggressor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidePotsPayInLayers(t *testing.T) {
|
||||||
|
// Three players all-in for different amounts. The short stack can only win
|
||||||
|
// what everyone could have lost to them.
|
||||||
|
s := State{
|
||||||
|
Tier: Tiers[1],
|
||||||
|
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||||
|
}
|
||||||
|
s.Seats[0].Total, s.Seats[0].State = 100, AllIn // short
|
||||||
|
s.Seats[1].Total, s.Seats[1].State = 500, AllIn // middle
|
||||||
|
s.Seats[2].Total, s.Seats[2].State = 500, AllIn // covers
|
||||||
|
|
||||||
|
s.sidePots()
|
||||||
|
|
||||||
|
if len(s.Side) != 2 {
|
||||||
|
t.Fatalf("got %d pots, want 2: %+v", len(s.Side), s.Side)
|
||||||
|
}
|
||||||
|
main, side := s.Side[0], s.Side[1]
|
||||||
|
if main.Amount != 300 { // 100 from each of the three
|
||||||
|
t.Errorf("main pot is %d, want 300", main.Amount)
|
||||||
|
}
|
||||||
|
if len(main.Eligible) != 3 {
|
||||||
|
t.Errorf("main pot has %d eligible, want all 3", len(main.Eligible))
|
||||||
|
}
|
||||||
|
if side.Amount != 800 { // 400 more from each of the two who had it
|
||||||
|
t.Errorf("side pot is %d, want 800", side.Amount)
|
||||||
|
}
|
||||||
|
if len(side.Eligible) != 2 {
|
||||||
|
t.Errorf("side pot has %d eligible, want 2 — the short stack cannot win it", len(side.Eligible))
|
||||||
|
}
|
||||||
|
if total := main.Amount + side.Amount; total != 1100 {
|
||||||
|
t.Errorf("the pots hold %d, but %d went in", total, 1100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A covered all-in player can only ever win what they matched, and the hand does
|
||||||
|
// not have to end in a run-out for that to be true.
|
||||||
|
//
|
||||||
|
// This one got through everything. The side pots were only ever cut in runout(),
|
||||||
|
// which happens when the betting stops because *nobody* can bet — so a short stack
|
||||||
|
// who shoves and gets called by two players who still have chips behind, and who
|
||||||
|
// then keep betting past them all the way to the river, reached a showdown with the
|
||||||
|
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
|
||||||
|
//
|
||||||
|
// Chip conservation never saw it: the chips balance perfectly, they just land in
|
||||||
|
// the wrong seat. And every browser session went through runout(), because the
|
||||||
|
// player shoving is what ends the betting. It took reading the code.
|
||||||
|
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
|
||||||
|
c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||||
|
|
||||||
|
s := State{
|
||||||
|
Tier: Tiers[1],
|
||||||
|
Phase: PhaseBetting,
|
||||||
|
Street: River,
|
||||||
|
Community: []cards.Card{
|
||||||
|
c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
|
||||||
|
c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
|
||||||
|
},
|
||||||
|
Seats: []Seat{
|
||||||
|
{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
|
||||||
|
// All-in for 100, and holding the best hand at the table.
|
||||||
|
{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
|
||||||
|
{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.Seats[0].Total, s.Seats[0].State = 500, Active
|
||||||
|
s.Seats[1].Total, s.Seats[1].State = 100, AllIn
|
||||||
|
s.Seats[2].Total, s.Seats[2].State = 500, Active
|
||||||
|
s.Pot = 1100 // 100 + 500 + 500
|
||||||
|
|
||||||
|
var evs []Event
|
||||||
|
s.showdown(&evs)
|
||||||
|
|
||||||
|
// The main pot is 100 from each of the three. The other 800 is between the two
|
||||||
|
// who were still betting, and the short stack cannot touch it.
|
||||||
|
if s.Seats[1].Won != 300 {
|
||||||
|
t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
|
||||||
|
"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
|
||||||
|
}
|
||||||
|
if s.Seats[0].Won+s.Seats[2].Won != 800 {
|
||||||
|
t.Errorf("the 800 side pot paid out %d between the two players who were "+
|
||||||
|
"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
|
||||||
|
s := State{
|
||||||
|
Tier: Tiers[1],
|
||||||
|
Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}, {Name: "B", Bot: true}},
|
||||||
|
}
|
||||||
|
s.Seats[0].Total, s.Seats[0].State = 200, AllIn
|
||||||
|
s.Seats[1].Total, s.Seats[1].State = 50, Folded // called 50 and gave up
|
||||||
|
s.Seats[2].Total, s.Seats[2].State = 200, AllIn
|
||||||
|
|
||||||
|
s.sidePots()
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
for _, p := range s.Side {
|
||||||
|
total += p.Amount
|
||||||
|
for _, seat := range p.Eligible {
|
||||||
|
if seat == 1 {
|
||||||
|
t.Error("a folded seat is eligible to win a pot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != 450 {
|
||||||
|
t.Errorf("the pots hold %d, want 450 — the folder's 50 has to still be in there", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUncalledBetComesBack(t *testing.T) {
|
||||||
|
s := State{
|
||||||
|
Tier: Tiers[1],
|
||||||
|
Seats: []Seat{{Name: "You", Stack: 0}, {Name: "A", Bot: true}},
|
||||||
|
}
|
||||||
|
s.Seats[0].Total, s.Seats[0].Bet, s.Seats[0].State = 500, 500, AllIn
|
||||||
|
s.Seats[1].Total, s.Seats[1].State = 200, Folded
|
||||||
|
|
||||||
|
var evs []Event
|
||||||
|
s.uncalled(&evs)
|
||||||
|
|
||||||
|
if s.Seats[You].Stack != 300 {
|
||||||
|
t.Errorf("got %d back, want the 300 nobody called", s.Seats[You].Stack)
|
||||||
|
}
|
||||||
|
if s.Seats[You].Total != 200 {
|
||||||
|
t.Errorf("still committed for %d, want 200 — the rest was never in the pot", s.Seats[You].Total)
|
||||||
|
}
|
||||||
|
if s.Seats[You].State != Active {
|
||||||
|
t.Error("still marked all-in for chips that came back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rake --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNoFlopNoDrop(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[1], Flopped: false, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||||
|
var evs []Event
|
||||||
|
s.payPot(Pot{Amount: 1000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||||
|
|
||||||
|
if s.Rake != 0 {
|
||||||
|
t.Errorf("raked %d off a pot that never saw a flop", s.Rake)
|
||||||
|
}
|
||||||
|
if s.Seats[You].Stack != 1000 {
|
||||||
|
t.Errorf("paid %d of a 1000 pot", s.Seats[You].Stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRakeIsCapped(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[1], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||||
|
var evs []Event
|
||||||
|
// 5% of 10,000 is 500, but the cap is three big blinds — 30 at 5/10.
|
||||||
|
s.payPot(Pot{Amount: 10000, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||||
|
|
||||||
|
want := s.Tier.BB * rakeCapBB
|
||||||
|
if s.Rake != want {
|
||||||
|
t.Errorf("raked %d, want the %d cap", s.Rake, want)
|
||||||
|
}
|
||||||
|
if s.Seats[You].Stack != 10000-want {
|
||||||
|
t.Errorf("paid %d, want %d", s.Seats[You].Stack, 10000-want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[0], Flopped: true, Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||||
|
var evs []Event
|
||||||
|
s.payPot(Pot{Amount: 100, Eligible: []int{You}}, []ranked{{seat: You}}, &evs) // cap is 6 at 1/2
|
||||||
|
|
||||||
|
if s.Rake != 5 {
|
||||||
|
t.Errorf("raked %d off a 100 pot, want 5", s.Rake)
|
||||||
|
}
|
||||||
|
if s.Seats[You].Stack != 95 {
|
||||||
|
t.Errorf("paid %d, want 95", s.Seats[You].Stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rake has to survive the wiring, not only the arithmetic.
|
||||||
|
//
|
||||||
|
// This is the test that was missing, and a browser found what it would have
|
||||||
|
// found: New() overwrites the tier's rake with the one the casino hands it, and
|
||||||
|
// the casino hands it a *fraction* (blackjack's 0.05). The tier declared 5,
|
||||||
|
// meaning percent. Every other rake test builds a State by hand and sets the tier
|
||||||
|
// itself, so not one of them ever saw the number a real table runs on — and the
|
||||||
|
// house quietly took nothing from every pot for an afternoon.
|
||||||
|
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
||||||
|
tier := Tiers[1] // 5/10, so the cap is 30
|
||||||
|
s, _, err := New(tier, 1, tier.MaxBuy, 0.05, 1, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Flopped = true
|
||||||
|
|
||||||
|
var evs []Event
|
||||||
|
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||||
|
|
||||||
|
if s.Rake != 20 {
|
||||||
|
t.Fatalf("the house took %d of a 400 pot, want 20 — five percent of it. "+
|
||||||
|
"RakePct is a fraction (0.05), not a percentage (5): see the note on Tiers.", s.Rake)
|
||||||
|
}
|
||||||
|
if !has(evs, "rake") {
|
||||||
|
t.Error("the rake was taken with no event to say so, so the felt cannot show it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The house only makes money off you. A pot a bot wins is raked — that is what a
|
||||||
|
// pot is — but the chips it comes out of are not real, so it has cost you nothing
|
||||||
|
// and the number the felt quotes you must not move.
|
||||||
|
func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[1], Flopped: true,
|
||||||
|
Seats: []Seat{{Name: "You"}, {Name: "Dice", Bot: true}}}
|
||||||
|
var evs []Event
|
||||||
|
|
||||||
|
// A bot takes it.
|
||||||
|
s.payPot(Pot{Amount: 400, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs)
|
||||||
|
if s.Paid != 0 {
|
||||||
|
t.Errorf("you paid %d in rake on a pot a bot won", s.Paid)
|
||||||
|
}
|
||||||
|
if s.Rake != 20 {
|
||||||
|
t.Errorf("the table lifted %d off that pot, want 20 — the chips have to balance "+
|
||||||
|
"whoever won it", s.Rake)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now you take one.
|
||||||
|
s.payPot(Pot{Amount: 400, Eligible: []int{You}}, []ranked{{seat: You}}, &evs)
|
||||||
|
if s.Paid != 20 {
|
||||||
|
t.Errorf("you paid %d in rake on a 400 pot you won, want 20", s.Paid)
|
||||||
|
}
|
||||||
|
if s.Rake != 40 {
|
||||||
|
t.Errorf("the table has lifted %d in total, want 40", s.Rake)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a chop costs you half of it.
|
||||||
|
s.Paid, s.Rake = 0, 0
|
||||||
|
s.payPot(Pot{Amount: 400, Eligible: []int{0, 1}},
|
||||||
|
[]ranked{{seat: 0, rank: 9}, {seat: 1, rank: 9}}, &evs)
|
||||||
|
if s.Paid != 10 {
|
||||||
|
t.Errorf("you paid %d in rake on a chopped pot, want 10 — half the rake, "+
|
||||||
|
"because you won half the pot", s.Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASplitPotSplits(t *testing.T) {
|
||||||
|
s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}}
|
||||||
|
var evs []Event
|
||||||
|
// Same rank: they chop. The odd chip goes to one of them, not into the air.
|
||||||
|
s.payPot(Pot{Amount: 101, Eligible: []int{0, 1}},
|
||||||
|
[]ranked{{seat: 0, rank: 500}, {seat: 1, rank: 500}}, &evs)
|
||||||
|
|
||||||
|
if got := s.Seats[0].Stack + s.Seats[1].Stack; got != 101 {
|
||||||
|
t.Errorf("paid out %d of a 101 pot", got)
|
||||||
|
}
|
||||||
|
if s.Seats[0].Stack != 51 || s.Seats[1].Stack != 50 {
|
||||||
|
t.Errorf("split %d/%d, want 51/50", s.Seats[0].Stack, s.Seats[1].Stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the session -----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 2, 200)
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
if s.Phase != PhaseBetting {
|
||||||
|
t.Skip("the hand ended before the player could act")
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||||
|
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||||
|
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 1, 200)
|
||||||
|
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
s = playOut(t, s)
|
||||||
|
|
||||||
|
stack := s.Seats[You].Stack
|
||||||
|
s, _, err := ApplyMove(s, Move{Kind: Leave})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Errorf("phase %q after leaving, want done", s.Phase)
|
||||||
|
}
|
||||||
|
if s.Payout != stack {
|
||||||
|
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
||||||
|
}
|
||||||
|
if _, _, err := ApplyMove(s, Move{Kind: Deal}); err != ErrOver {
|
||||||
|
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBustingEndsTheSession(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 1, 200)
|
||||||
|
s.Seats[You].Stack = 0
|
||||||
|
var evs []Event
|
||||||
|
s.endHand(&evs)
|
||||||
|
|
||||||
|
if s.Phase != PhaseDone {
|
||||||
|
t.Errorf("phase %q with no chips left, want done", s.Phase)
|
||||||
|
}
|
||||||
|
if s.Payout != 0 {
|
||||||
|
t.Errorf("payout %d for a busted player", s.Payout)
|
||||||
|
}
|
||||||
|
if !has(evs, "bust") {
|
||||||
|
t.Error("no bust event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||||
|
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
||||||
|
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||||
|
t.Errorf("topped up over the table maximum: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s = table(t, Tiers[0], 1, 100)
|
||||||
|
s, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Seats[You].Stack != 150 {
|
||||||
|
t.Errorf("stack is %d, want 150", s.Seats[You].Stack)
|
||||||
|
}
|
||||||
|
if s.BoughtIn != 150 {
|
||||||
|
t.Errorf("bought in for %d, want 150 — the top-up is real money too", s.BoughtIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuyInHasToBeInRange(t *testing.T) {
|
||||||
|
tier := Tiers[0]
|
||||||
|
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
||||||
|
if _, _, err := New(tier, 1, amount, 5, 1, 2); err != ErrBadBuyIn {
|
||||||
|
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what the player is allowed to know ------------------------------------
|
||||||
|
|
||||||
|
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(4, 4))
|
||||||
|
s := table(t, Tiers[0], 3, 200)
|
||||||
|
|
||||||
|
for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ {
|
||||||
|
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
noBotCards(t, s, evs)
|
||||||
|
|
||||||
|
for s.Phase == PhaseBetting {
|
||||||
|
var next []Event
|
||||||
|
s, next, err = ApplyMove(s, randomMove(s, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
noBotCards(t, s, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bot's cards may appear in exactly one kind of event: the showdown that turns
|
||||||
|
// them face up, which is the moment they stop being secret.
|
||||||
|
func noBotCards(t *testing.T, s State, evs []Event) {
|
||||||
|
t.Helper()
|
||||||
|
for _, e := range evs {
|
||||||
|
if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Seat != You && s.Seats[e.Seat].Bot {
|
||||||
|
t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand",
|
||||||
|
e.Kind, e.Seat, e.Cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- hand strength ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestTheEvaluatorKnowsWhichHandIsBetter(t *testing.T) {
|
||||||
|
board := []cards.Card{
|
||||||
|
{Rank: 10, Suit: cards.Hearts}, {Rank: cards.Jack, Suit: cards.Hearts},
|
||||||
|
{Rank: cards.Queen, Suit: cards.Hearts}, {Rank: 2, Suit: cards.Spades},
|
||||||
|
{Rank: 7, Suit: cards.Clubs},
|
||||||
|
}
|
||||||
|
flush, _ := rankOf([2]cards.Card{{Rank: 3, Suit: cards.Hearts}, {Rank: 5, Suit: cards.Hearts}}, board)
|
||||||
|
straight, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Clubs}}, board)
|
||||||
|
royal, _ := rankOf([2]cards.Card{{Rank: cards.King, Suit: cards.Hearts}, {Rank: cards.Ace, Suit: cards.Hearts}}, board)
|
||||||
|
pair, _ := rankOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 4, Suit: cards.Diamonds}}, board)
|
||||||
|
|
||||||
|
if !(royal < flush && flush < straight && straight < pair) {
|
||||||
|
t.Errorf("hands rank royal=%d flush=%d straight=%d pair=%d — lower must be better, in that order",
|
||||||
|
royal, flush, straight, pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEquityKnowsAcesAreGood(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(1, 1))
|
||||||
|
aces := equityOf([2]cards.Card{{Rank: cards.Ace, Suit: cards.Spades}, {Rank: cards.Ace, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||||
|
rags := equityOf([2]cards.Card{{Rank: 7, Suit: cards.Spades}, {Rank: 2, Suit: cards.Hearts}}, nil, 1, 2000, rng)
|
||||||
|
|
||||||
|
if aces.Strength() < 0.8 {
|
||||||
|
t.Errorf("pocket aces are worth %.2f heads-up, want about 0.85", aces.Strength())
|
||||||
|
}
|
||||||
|
if rags.Strength() > 0.4 {
|
||||||
|
t.Errorf("seven-deuce is worth %.2f heads-up, want about 0.35", rags.Strength())
|
||||||
|
}
|
||||||
|
if aces.Strength() <= rags.Strength() {
|
||||||
|
t.Error("seven-deuce is not better than pocket aces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The policy loads, and every node in it is a probability distribution.
|
||||||
|
func TestThePolicyLoads(t *testing.T) {
|
||||||
|
p := loadPolicy()
|
||||||
|
if len(p) < 1000 {
|
||||||
|
t.Fatalf("the CFR policy has %d nodes in it — it did not load, or it was never trained", len(p))
|
||||||
|
}
|
||||||
|
for key, probs := range p {
|
||||||
|
var sum float64
|
||||||
|
for _, v := range probs {
|
||||||
|
if v < 0 {
|
||||||
|
t.Fatalf("%s: a negative probability (%v)", key, probs)
|
||||||
|
}
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
if sum < 0.99 || sum > 1.01 {
|
||||||
|
t.Fatalf("%s: the probabilities sum to %v, not 1", key, sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheBotsAreActuallyTrained is the test this game most needed and did not
|
||||||
|
// have.
|
||||||
|
//
|
||||||
|
// A bot that cannot find itself in the policy does not fail. It shrugs, plays the
|
||||||
|
// pot-odds rule, and looks exactly like a bot that is working — which is how
|
||||||
|
// gogobee shipped a trained poker AI whose policy was *never read once* for the
|
||||||
|
// entire life of the game. The trainer wrote its keys under IP/OOP and the table
|
||||||
|
// looked them up under BTN/SB/BB, and there was nothing anywhere that would have
|
||||||
|
// said so.
|
||||||
|
//
|
||||||
|
// So: deal real hands, let the bots think, and count how often the thinking lands
|
||||||
|
// in the table. Heads-up is the number that has to hold — that is what the policy
|
||||||
|
// was trained on. A six-handed table is a documented approximation of it and
|
||||||
|
// drops off as seats are added, which is why this only asserts on the duel.
|
||||||
|
func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||||
|
hits.Store(0)
|
||||||
|
misses.Store(0)
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewPCG(11, 12))
|
||||||
|
for game := 0; game < 40; game++ {
|
||||||
|
tier := Tiers[1]
|
||||||
|
s, _, err := New(tier, 1, tier.MaxBuy, tier.RakePct, uint64(game), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
||||||
|
s, _, err = ApplyMove(s, Move{Kind: Deal})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for s.Phase == PhaseBetting {
|
||||||
|
s, _, err = ApplyMove(s, randomMove(s, rng))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h, m := hits.Load(), misses.Load()
|
||||||
|
if h+m < 100 {
|
||||||
|
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
|
||||||
|
}
|
||||||
|
rate := float64(h) / float64(h+m)
|
||||||
|
if rate < 0.6 {
|
||||||
|
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
|
||||||
|
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
|
||||||
|
"trainer writes and the key the table reads have drifted apart. See infoSet.",
|
||||||
|
rate*100, h, h+m)
|
||||||
|
}
|
||||||
|
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
||||||
|
t.Helper()
|
||||||
|
s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new table: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// playOut folds every decision until the hand is over.
|
||||||
|
func playOut(t *testing.T, s State) State {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; s.Phase == PhaseBetting; i++ {
|
||||||
|
if i > 100 {
|
||||||
|
t.Fatal("the hand will not end")
|
||||||
|
}
|
||||||
|
move := Move{Kind: Fold}
|
||||||
|
if s.Owed(You) == 0 {
|
||||||
|
move = Move{Kind: Check}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
s, _, err = ApplyMove(s, move)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("playing out: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(evs []Event, kind string) bool {
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.Kind == kind {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
BIN
internal/games/holdem/policy.gob
Normal file
BIN
internal/games/holdem/policy.gob
Normal file
Binary file not shown.
431
internal/games/holdem/train.go
Normal file
431
internal/games/holdem/train.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package holdem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"pete/internal/games/cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The trainer.
|
||||||
|
//
|
||||||
|
// This is counterfactual regret minimisation, and what it produces is policy.gob
|
||||||
|
// — the table the bots read at the table. It is not on any request path; it runs
|
||||||
|
// from cmd/holdem-train, for half an hour, and then it is a file.
|
||||||
|
//
|
||||||
|
// The one thing worth understanding about it: **it plays the real game.** Every
|
||||||
|
// move it explores goes through Step, which is the same reducer the felt calls,
|
||||||
|
// so the blinds, the min-raise, the street completion and the money are the ones
|
||||||
|
// a player will actually meet. Its info-set key comes out of State.spot, which is
|
||||||
|
// the same function the bots look themselves up with.
|
||||||
|
//
|
||||||
|
// That is not tidiness, it is the whole lesson of the policy this replaces. That
|
||||||
|
// one was trained against a hand-written model of poker sitting beside the real
|
||||||
|
// engine — a model where a call always ended the street, the big blind had no
|
||||||
|
// option, and the payoff was half the pot no matter who had put what in. Then it
|
||||||
|
// was looked up under a key the trainer never wrote. The result was a 3.4MB file
|
||||||
|
// that had never once been read, and nobody could tell, because a policy miss is
|
||||||
|
// not an error. It just quietly isn't there.
|
||||||
|
//
|
||||||
|
// So: one engine, one key function, and a test that fails if the bots stop
|
||||||
|
// finding themselves in the table.
|
||||||
|
|
||||||
|
// How much of the game tree to explore. Two raises a street keeps the tree small
|
||||||
|
// enough to converge; a third barely changes how anybody plays and multiplies the
|
||||||
|
// nodes.
|
||||||
|
const (
|
||||||
|
maxRaisesPerStreet = 2
|
||||||
|
maxDepth = 40
|
||||||
|
trainMCIters = 60 // noisy, but it is only picking a bucket
|
||||||
|
)
|
||||||
|
|
||||||
|
// regrets is what CFR accumulates: how much better each action would have been.
|
||||||
|
type regrets map[string]*[numActions]float64
|
||||||
|
|
||||||
|
// Trained is the file the bots read.
|
||||||
|
type Trained struct {
|
||||||
|
Strategy map[string][numActions]float64
|
||||||
|
Meta TrainMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainMeta is what the policy can say about itself. Worth having: a policy is
|
||||||
|
// otherwise an opaque three megabytes and there is no way to tell a good one from
|
||||||
|
// a stale one by looking.
|
||||||
|
type TrainMeta struct {
|
||||||
|
Iterations int
|
||||||
|
Stakes string
|
||||||
|
Depths string
|
||||||
|
Nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- preflop, measured once ------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
preflopOnce sync.Once
|
||||||
|
preflopTable [13][13]Equity // [hi][lo] offsuit, [lo][hi] suited, diagonal pairs
|
||||||
|
)
|
||||||
|
|
||||||
|
// preflopEquity is the equity of a starting hand heads-up. There are only 169
|
||||||
|
// hands that differ from each other, so they are measured properly, once, and
|
||||||
|
// then it is a lookup — which matters twice: it takes the noise out of a bucket
|
||||||
|
// boundary, and the trainer visits preflop on every single iteration.
|
||||||
|
func preflopEquity(hole [2]cards.Card) Equity {
|
||||||
|
preflopOnce.Do(func() {
|
||||||
|
rng := cards.NewRNG(20260714, 1)
|
||||||
|
for a := cards.Ace; a <= cards.King; a++ {
|
||||||
|
for b := a; b <= cards.King; b++ {
|
||||||
|
lo, hi := rankIdx(a), rankIdx(b)
|
||||||
|
|
||||||
|
// Suited, and the pairs (which can only be offsuit) on the diagonal.
|
||||||
|
s1 := cards.Card{Rank: a, Suit: cards.Spades}
|
||||||
|
s2 := cards.Card{Rank: b, Suit: cards.Spades}
|
||||||
|
if a == b {
|
||||||
|
s2.Suit = cards.Hearts
|
||||||
|
}
|
||||||
|
preflopTable[lo][hi] = equityOf([2]cards.Card{s1, s2}, nil, 1, 10000, rng)
|
||||||
|
|
||||||
|
if a != b {
|
||||||
|
o2 := cards.Card{Rank: b, Suit: cards.Hearts}
|
||||||
|
preflopTable[hi][lo] = equityOf([2]cards.Card{s1, o2}, nil, 1, 10000, rng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
lo, hi := rankIdx(hole[0].Rank), rankIdx(hole[1].Rank)
|
||||||
|
if lo > hi {
|
||||||
|
lo, hi = hi, lo
|
||||||
|
}
|
||||||
|
if hole[0].Suit == hole[1].Suit {
|
||||||
|
return preflopTable[lo][hi] // suited, and the pairs sit here too
|
||||||
|
}
|
||||||
|
if lo == hi {
|
||||||
|
return preflopTable[lo][hi]
|
||||||
|
}
|
||||||
|
return preflopTable[hi][lo] // offsuit
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankIdx maps a rank to 0–12, with the ace high — which is what it is, before
|
||||||
|
// the flop.
|
||||||
|
func rankIdx(r cards.Rank) int {
|
||||||
|
if r == cards.Ace {
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
return int(r) - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the traversal ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Train runs external-sampling MCCFR for n hands and returns the average
|
||||||
|
// strategy. Each worker keeps its own tables and they are summed at the end,
|
||||||
|
// which is what makes this embarrassingly parallel and is the only reason it
|
||||||
|
// finishes in half an hour.
|
||||||
|
func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress func(done int)) *Trained {
|
||||||
|
if workers < 1 {
|
||||||
|
workers = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
type table struct {
|
||||||
|
reg regrets
|
||||||
|
avg regrets
|
||||||
|
}
|
||||||
|
out := make([]table, workers)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var done sync.Mutex
|
||||||
|
completed := 0
|
||||||
|
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(w int) {
|
||||||
|
defer wg.Done()
|
||||||
|
tr := &trainer{
|
||||||
|
reg: regrets{},
|
||||||
|
avg: regrets{},
|
||||||
|
tier: t,
|
||||||
|
minBB: minBB,
|
||||||
|
maxBB: maxBB,
|
||||||
|
rng: cards.NewRNG(seed, uint64(w)+1),
|
||||||
|
}
|
||||||
|
|
||||||
|
share := n / workers
|
||||||
|
if w < n%workers {
|
||||||
|
share++
|
||||||
|
}
|
||||||
|
for i := 0; i < share; i++ {
|
||||||
|
tr.iterate(uint64(w)<<40 | uint64(i))
|
||||||
|
// i+1, not i: the check fired on the very first pass and credited two
|
||||||
|
// thousand hands before a single one had been walked, which with thirty
|
||||||
|
// workers made the first ETA sixty thousand hands optimistic.
|
||||||
|
if progress != nil && (i+1)%2000 == 0 {
|
||||||
|
done.Lock()
|
||||||
|
completed += 2000
|
||||||
|
c := completed
|
||||||
|
done.Unlock()
|
||||||
|
progress(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[w] = table{tr.reg, tr.avg}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Sum the workers' average-strategy tables, then normalise each node into the
|
||||||
|
// probabilities a bot will actually play.
|
||||||
|
total := regrets{}
|
||||||
|
for _, tab := range out {
|
||||||
|
for key, v := range tab.avg {
|
||||||
|
acc, ok := total[key]
|
||||||
|
if !ok {
|
||||||
|
acc = &[numActions]float64{}
|
||||||
|
total[key] = acc
|
||||||
|
}
|
||||||
|
for i, x := range v {
|
||||||
|
acc[i] += x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := make(map[string][numActions]float64, len(total))
|
||||||
|
for key, v := range total {
|
||||||
|
var sum float64
|
||||||
|
for _, x := range v {
|
||||||
|
sum += x
|
||||||
|
}
|
||||||
|
var probs [numActions]float64
|
||||||
|
if sum > 0 {
|
||||||
|
for i, x := range v {
|
||||||
|
probs[i] = x / sum
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i := range probs {
|
||||||
|
probs[i] = 1.0 / numActions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strategy[key] = probs
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Trained{
|
||||||
|
Strategy: strategy,
|
||||||
|
Meta: TrainMeta{
|
||||||
|
Iterations: n,
|
||||||
|
Stakes: fmt.Sprintf("%d/%d", t.SB, t.BB),
|
||||||
|
Depths: fmt.Sprintf("%d–%d BB", minBB, maxBB),
|
||||||
|
Nodes: len(strategy),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trainer struct {
|
||||||
|
reg regrets
|
||||||
|
avg regrets
|
||||||
|
tier Tier
|
||||||
|
minBB int64
|
||||||
|
maxBB int64
|
||||||
|
rng *rand.Rand
|
||||||
|
|
||||||
|
// A hand's equity on a given street depends on the cards and nothing else —
|
||||||
|
// not on how the betting went to get there. The deck is fixed for the whole
|
||||||
|
// iteration, so the flop is the same flop down every branch, and this is
|
||||||
|
// measured once per seat per street instead of once per node.
|
||||||
|
eq [2][4]Equity
|
||||||
|
have [2][4]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterate deals one hand and walks it once for each player.
|
||||||
|
//
|
||||||
|
// The stack depth is drawn fresh every hand, across the whole range the table
|
||||||
|
// allows. This is the fix for the policy that came before: it was trained at ten
|
||||||
|
// big blinds and nothing else, so four out of five spots in a real cash game fell
|
||||||
|
// outside anything it had ever seen. A hand of poker is a different game at 20
|
||||||
|
// big blinds than at 100 — that is most of what makes it a game — and the bots
|
||||||
|
// have to have played both.
|
||||||
|
func (tr *trainer) iterate(id uint64) {
|
||||||
|
depth := tr.minBB
|
||||||
|
if tr.maxBB > tr.minBB {
|
||||||
|
depth += tr.rng.Int64N(tr.maxBB - tr.minBB + 1)
|
||||||
|
}
|
||||||
|
stack := depth * tr.tier.BB
|
||||||
|
|
||||||
|
// No rake while learning. The bots should learn to play poker, not to beat a
|
||||||
|
// fee, and the fee is the house's business.
|
||||||
|
t := tr.tier
|
||||||
|
t.RakePct = 0
|
||||||
|
|
||||||
|
s, err := open(t, stack, stack, id, tr.rng.Uint64())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := [2]int64{s.Seats[0].Stack + s.Seats[0].Bet, s.Seats[1].Stack + s.Seats[1].Bet}
|
||||||
|
|
||||||
|
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
|
||||||
|
|
||||||
|
for me := 0; me < 2; me++ {
|
||||||
|
tr.walk(s.clone(), me, start, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// equity is the cached measurement for this seat on this street.
|
||||||
|
func (tr *trainer) equity(s State, seat int) Equity {
|
||||||
|
st := s.Street
|
||||||
|
if st > River {
|
||||||
|
st = River
|
||||||
|
}
|
||||||
|
if !tr.have[seat][st] {
|
||||||
|
tr.eq[seat][st] = s.equityFor(seat, trainMCIters, tr.rng)
|
||||||
|
tr.have[seat][st] = true
|
||||||
|
}
|
||||||
|
return tr.eq[seat][st]
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk returns what the hand is worth to `me`, in chips, from here.
|
||||||
|
func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
|
||||||
|
if s.Phase != PhaseBetting || depth > maxDepth {
|
||||||
|
// The hand is over (or we have gone far enough to call it over). What it was
|
||||||
|
// worth is simply what the player has now against what they sat down with —
|
||||||
|
// the real number, out of the real engine, side pots and all.
|
||||||
|
return float64(s.Seats[me].Stack - start[me])
|
||||||
|
}
|
||||||
|
|
||||||
|
seat := s.ToAct
|
||||||
|
key := s.spotKey(seat, tr.equity(s, seat))
|
||||||
|
|
||||||
|
mask := s.mask(seat)
|
||||||
|
if raises(s.History) >= maxRaisesPerStreet {
|
||||||
|
mask[actRaiseHalf], mask[actRaisePot] = false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := tr.reg[key]
|
||||||
|
if reg == nil {
|
||||||
|
reg = &[numActions]float64{}
|
||||||
|
tr.reg[key] = reg
|
||||||
|
}
|
||||||
|
strat := match(*reg, mask)
|
||||||
|
|
||||||
|
// The opponent's turn: sample one line and follow it. That is the "external
|
||||||
|
// sampling" part, and it is what keeps a hand from costing 5^12 traversals.
|
||||||
|
if seat != me {
|
||||||
|
avg := tr.avg[key]
|
||||||
|
if avg == nil {
|
||||||
|
avg = &[numActions]float64{}
|
||||||
|
tr.avg[key] = avg
|
||||||
|
}
|
||||||
|
for i, p := range strat {
|
||||||
|
avg[i] += p
|
||||||
|
}
|
||||||
|
return tr.walk(tr.play(s, seat, sample(strat, tr.rng)), me, start, depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our turn: try everything, and regret what we didn't do.
|
||||||
|
var values [numActions]float64
|
||||||
|
var node float64
|
||||||
|
for a := 0; a < numActions; a++ {
|
||||||
|
if !mask[a] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[a] = tr.walk(tr.play(s, seat, a), me, start, depth+1)
|
||||||
|
node += strat[a] * values[a]
|
||||||
|
}
|
||||||
|
for a := 0; a < numActions; a++ {
|
||||||
|
if mask[a] {
|
||||||
|
reg[a] += values[a] - node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// play applies one abstract action through the real reducer.
|
||||||
|
func (tr *trainer) play(s State, seat, action int) State {
|
||||||
|
next, _, err := step(s.clone(), s.moveFor(action, seat))
|
||||||
|
if err != nil {
|
||||||
|
// The mask and the rules disagreed, which is a bug in one of them. Fold and
|
||||||
|
// carry on rather than poison the whole run.
|
||||||
|
next, _, err = step(s.clone(), Move{Kind: Fold})
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// match is regret matching: play each action in proportion to how much you wish
|
||||||
|
// you had played it. An action nobody regrets not taking gets played uniformly.
|
||||||
|
func match(reg [numActions]float64, mask [numActions]bool) [numActions]float64 {
|
||||||
|
var strat [numActions]float64
|
||||||
|
var sum float64
|
||||||
|
for i, r := range reg {
|
||||||
|
if mask[i] && r > 0 {
|
||||||
|
sum += r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum > 0 {
|
||||||
|
for i, r := range reg {
|
||||||
|
if mask[i] && r > 0 {
|
||||||
|
strat[i] = r / sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strat
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, ok := range mask {
|
||||||
|
if ok {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
strat[actCallCheck] = 1
|
||||||
|
return strat
|
||||||
|
}
|
||||||
|
for i, ok := range mask {
|
||||||
|
if ok {
|
||||||
|
strat[i] = 1 / float64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strat
|
||||||
|
}
|
||||||
|
|
||||||
|
func sample(strat [numActions]float64, rng *rand.Rand) int {
|
||||||
|
r := rng.Float64()
|
||||||
|
var sum float64
|
||||||
|
for i, p := range strat {
|
||||||
|
sum += p
|
||||||
|
if r < sum {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actCallCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// raises counts the bets and raises on this street, which is what the tree is
|
||||||
|
// capped on.
|
||||||
|
func raises(history string) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range history {
|
||||||
|
if c == 'r' || c == 'R' {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the file --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Save writes a trained policy.
|
||||||
|
func Save(w io.Writer, t *Trained) error { return gob.NewEncoder(w).Encode(t) }
|
||||||
|
|
||||||
|
// Load reads one. It is only used by the tests — the bots read the embedded copy.
|
||||||
|
func Load(r io.Reader) (*Trained, error) {
|
||||||
|
var t Trained
|
||||||
|
if err := gob.NewDecoder(r).Decode(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTrained decodes the embedded policy in the new format.
|
||||||
|
func loadTrained(b []byte) (*Trained, error) { return Load(bytes.NewReader(b)) }
|
||||||
349
internal/web/games_holdem.go
Normal file
349
internal/web/games_holdem.go
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/games/blackjack"
|
||||||
|
"pete/internal/games/holdem"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Texas hold'em, played for chips against the trained bots.
|
||||||
|
//
|
||||||
|
// This is the only table in the casino that is a *session* rather than a game.
|
||||||
|
// Everywhere else you stake, you play once, and a multiple pays: the hand is the
|
||||||
|
// unit and the money moves at both ends of it. Poker is not that shape. You buy
|
||||||
|
// chips onto the table, you play as many hands as you feel like, and you leave
|
||||||
|
// with whatever is in front of you — so the live row lives across hands, and the
|
||||||
|
// chips move exactly twice: once when you sit down, once when you get up.
|
||||||
|
//
|
||||||
|
// Which means there is no "payout" until you stand up, and `commit` is only ever
|
||||||
|
// told the game is Done when you do (or when you have nothing left to sit with).
|
||||||
|
// In between, every pot won and lost is inside the engine, and storage sees none
|
||||||
|
// of it. That is the honest model, and it is also the safe one: a hand that dies
|
||||||
|
// halfway leaves the chips where they were, on the table, in the live row.
|
||||||
|
//
|
||||||
|
// What the browser is allowed to see: your two cards, the board, everybody's
|
||||||
|
// stacks and bets, and nothing else. Not the deck, and not a bot's hand — until
|
||||||
|
// a showdown turns it over, which is the moment it stops being a secret.
|
||||||
|
|
||||||
|
// holdemSeatView is one seat. Cards are present only when the viewer is entitled
|
||||||
|
// to them: yours always, a bot's never, until the hand is shown down.
|
||||||
|
type holdemSeatView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bot bool `json:"bot"`
|
||||||
|
You bool `json:"you"`
|
||||||
|
Stack int64 `json:"stack"`
|
||||||
|
Bet int64 `json:"bet"`
|
||||||
|
State string `json:"state"` // active | folded | allin | out
|
||||||
|
Pos string `json:"pos"` // BTN, SB, BB, UTG…
|
||||||
|
Cards []cardView `json:"cards,omitempty"`
|
||||||
|
Won int64 `json:"won,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var seatStates = map[holdem.SeatState]string{
|
||||||
|
holdem.Active: "active",
|
||||||
|
holdem.Folded: "folded",
|
||||||
|
holdem.AllIn: "allin",
|
||||||
|
holdem.Out: "out",
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemView is the table as its player may see it.
|
||||||
|
type holdemView struct {
|
||||||
|
Tier holdem.Tier `json:"tier"`
|
||||||
|
Seats []holdemSeatView `json:"seats"`
|
||||||
|
Button int `json:"button"`
|
||||||
|
HandNo int `json:"hand_no"`
|
||||||
|
|
||||||
|
Board []cardView `json:"board"`
|
||||||
|
Street string `json:"street"`
|
||||||
|
Pot int64 `json:"pot"`
|
||||||
|
Side []int64 `json:"side,omitempty"`
|
||||||
|
|
||||||
|
ToAct int `json:"to_act"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
|
||||||
|
// What you may do, decided here rather than in the browser — the felt should
|
||||||
|
// never offer a button the table would refuse.
|
||||||
|
Owed int64 `json:"owed"`
|
||||||
|
CanCheck bool `json:"can_check"`
|
||||||
|
CanRaise bool `json:"can_raise"`
|
||||||
|
MinRaise int64 `json:"min_raise_to"`
|
||||||
|
MaxRaise int64 `json:"max_raise_to"`
|
||||||
|
|
||||||
|
Stack int64 `json:"stack"` // what's in front of you
|
||||||
|
BoughtIn int64 `json:"bought_in"`
|
||||||
|
Rake int64 `json:"rake"` // what the house has taken this session
|
||||||
|
MaxTopUp int64 `json:"max_topup"`
|
||||||
|
Payout int64 `json:"payout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewHoldem(g holdem.State) holdemView {
|
||||||
|
v := holdemView{
|
||||||
|
Tier: g.Tier,
|
||||||
|
Button: g.Button,
|
||||||
|
HandNo: g.HandNo,
|
||||||
|
Street: g.Street.String(),
|
||||||
|
Pot: g.Total(),
|
||||||
|
ToAct: g.ToAct,
|
||||||
|
Phase: string(g.Phase),
|
||||||
|
Stack: g.Seats[holdem.You].Stack,
|
||||||
|
BoughtIn: g.BoughtIn,
|
||||||
|
Rake: g.Paid, // the part you actually paid, not the part the table lifted
|
||||||
|
Payout: g.Payout,
|
||||||
|
}
|
||||||
|
for _, p := range g.Side {
|
||||||
|
v.Side = append(v.Side, p.Amount)
|
||||||
|
}
|
||||||
|
// An empty board is an empty board, not null. A Go slice with nothing in it
|
||||||
|
// marshals to null, and a browser that has to write `(board || [])` everywhere
|
||||||
|
// is a browser one forgotten guard away from a crash on every preflop.
|
||||||
|
v.Board = []cardView{}
|
||||||
|
for _, c := range g.Community {
|
||||||
|
v.Board = append(v.Board, viewCard(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wall. A bot's hand crosses the wire in exactly one situation — the hand
|
||||||
|
// was shown down and they did not fold — because that is the only situation in
|
||||||
|
// which a player at a real table would be looking at it.
|
||||||
|
shown := g.Street == holdem.Showdown
|
||||||
|
for i, p := range g.Seats {
|
||||||
|
seat := holdemSeatView{
|
||||||
|
Name: p.Name,
|
||||||
|
Bot: p.Bot,
|
||||||
|
You: i == holdem.You,
|
||||||
|
Stack: p.Stack,
|
||||||
|
Bet: p.Bet,
|
||||||
|
State: seatStates[p.State],
|
||||||
|
Pos: g.Position(i),
|
||||||
|
Won: p.Won,
|
||||||
|
}
|
||||||
|
mine := i == holdem.You
|
||||||
|
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
||||||
|
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
||||||
|
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
||||||
|
}
|
||||||
|
v.Seats = append(v.Seats, seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Phase == holdem.PhaseBetting && g.ToAct == holdem.You {
|
||||||
|
v.Owed = g.Owed(holdem.You)
|
||||||
|
v.CanCheck = v.Owed == 0
|
||||||
|
v.CanRaise = g.CanRaise(holdem.You)
|
||||||
|
v.MinRaise = g.MinRaiseTo(holdem.You)
|
||||||
|
v.MaxRaise = g.MaxRaiseTo(holdem.You)
|
||||||
|
}
|
||||||
|
if top := g.Tier.MaxBuy - g.Seats[holdem.You].Stack; top > 0 {
|
||||||
|
v.MaxTopUp = top
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemEventView is one beat of the script the felt plays back. The engine only
|
||||||
|
// ever attaches a bot's cards to a showdown; this drops them again anywhere else,
|
||||||
|
// which is the second of the two walls.
|
||||||
|
type holdemEventView struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Seat int `json:"seat"`
|
||||||
|
Cards []cardView `json:"cards,omitempty"`
|
||||||
|
Amount int64 `json:"amount,omitempty"`
|
||||||
|
Total int64 `json:"total,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewHoldemEvents(evs []holdem.Event) []holdemEventView {
|
||||||
|
out := make([]holdemEventView, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
||||||
|
for _, c := range e.Cards {
|
||||||
|
v.Cards = append(v.Cards, viewCard(c))
|
||||||
|
}
|
||||||
|
// A card may ride an event only if it is the board, your own hand, or a hand
|
||||||
|
// being shown down. Anything else is a bot's business.
|
||||||
|
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" {
|
||||||
|
v.Cards = nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHoldemSit buys chips onto a table. The chips are staked first, in the
|
||||||
|
// same statement that checks they exist, so two sit-downs fired at once cannot
|
||||||
|
// buy in with the same chip.
|
||||||
|
func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Bots int `json:"bots"`
|
||||||
|
BuyIn int64 `json:"buyin"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tier, err := holdem.TierBySlug(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "that isn't a legal buy-in for this table",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Bots < 1 || req.Bots > holdem.MaxBots {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.Stake(user, req.BuyIn); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("games: holdem buy-in", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seed1, seed2 := newSeeds()
|
||||||
|
g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||||
|
if err != nil {
|
||||||
|
_ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought
|
||||||
|
slog.Error("games: holdem sit", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistHoldem(w, user, g, evs, seed1, seed2, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHoldemMove plays one move: an action in a hand, or the three that are
|
||||||
|
// about the session — deal the next hand, put more chips on the table, get up.
|
||||||
|
func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.player(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var move holdem.Move
|
||||||
|
if err := decodeJSON(r, &move); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live, err := storage.LoadLiveHand(user)
|
||||||
|
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: holdem load", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if live.Game != gameHoldem {
|
||||||
|
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
slog.Error("games: unreadable holdem table", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A top-up is real money crossing the border, so the chips come off the stack
|
||||||
|
// before the engine is asked — and go straight back if it says no. Same order,
|
||||||
|
// and the same reason, as doubling down at blackjack.
|
||||||
|
topped := int64(0)
|
||||||
|
if move.Kind == holdem.TopUp {
|
||||||
|
if err := storage.Stake(user, move.Amount); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("games: holdem top-up", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
topped = move.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
next, evs, err := holdem.ApplyMove(g, move)
|
||||||
|
if err != nil {
|
||||||
|
if topped > 0 {
|
||||||
|
_ = storage.Award(user, topped) // the top-up didn't happen
|
||||||
|
}
|
||||||
|
msg := "that move isn't legal here"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, holdem.ErrHandLive):
|
||||||
|
msg = "finish the hand first"
|
||||||
|
case errors.Is(err, holdem.ErrNotYourTurn):
|
||||||
|
msg = "it isn't your turn"
|
||||||
|
case errors.Is(err, holdem.ErrCantCheck):
|
||||||
|
msg = "there's a bet to you"
|
||||||
|
case errors.Is(err, holdem.ErrTooSmall):
|
||||||
|
msg = "that's under the minimum raise"
|
||||||
|
case errors.Is(err, holdem.ErrTooBig):
|
||||||
|
msg = "you don't have that many chips"
|
||||||
|
case errors.Is(err, holdem.ErrBadBuyIn):
|
||||||
|
msg = "that would put you over the table maximum"
|
||||||
|
}
|
||||||
|
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistHoldem writes the table back and answers the browser.
|
||||||
|
//
|
||||||
|
// The session settles exactly once — when the player gets up, or when they have
|
||||||
|
// nothing left to get up with. Until then Done is false and `commit` moves no
|
||||||
|
// chips at all, which is what makes a hundred hands of poker a single trip across
|
||||||
|
// the border rather than a hundred.
|
||||||
|
func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) {
|
||||||
|
blob, err := json.Marshal(g)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: marshal holdem", "user", user, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done := g.Phase == holdem.PhaseDone
|
||||||
|
outcome := "left"
|
||||||
|
switch {
|
||||||
|
case done && g.Payout == 0:
|
||||||
|
outcome = "busted"
|
||||||
|
case done && g.Payout > g.BoughtIn:
|
||||||
|
outcome = "up"
|
||||||
|
case done && g.Payout < g.BoughtIn:
|
||||||
|
outcome = "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := s.commit(w, user, finished{
|
||||||
|
Game: gameHoldem, Blob: blob,
|
||||||
|
// Paid, not Rake: the audit log is the house's income, and the house only
|
||||||
|
// makes money off the player. What it lifts off a bot's pot is not income.
|
||||||
|
Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Paid,
|
||||||
|
Outcome: outcome, Done: done,
|
||||||
|
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A closed session is gone from storage, so the table view has none to show —
|
||||||
|
// but the browser still needs the last board to land the verdict on.
|
||||||
|
if done {
|
||||||
|
hv := viewHoldem(g)
|
||||||
|
v.Holdem = &hv
|
||||||
|
}
|
||||||
|
v.HoldemEvents = viewHoldemEvents(evs)
|
||||||
|
writeJSON(w, v)
|
||||||
|
}
|
||||||
239
internal/web/games_holdem_test.go
Normal file
239
internal/web/games_holdem_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pete/internal/games/holdem"
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sitting down is the only time chips leave your stack at this table, and getting
|
||||||
|
// up is the only time they come back. Everything in between is inside the engine.
|
||||||
|
func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 600}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("sit = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 4400 {
|
||||||
|
t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips)
|
||||||
|
}
|
||||||
|
if v.Game != gameHoldem || v.Holdem == nil {
|
||||||
|
t.Fatalf("sit returned no table: game=%q", v.Game)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := v.Holdem
|
||||||
|
if g.Stack != 600 {
|
||||||
|
t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack)
|
||||||
|
}
|
||||||
|
if len(g.Seats) != 3 {
|
||||||
|
t.Fatalf("two bots and you is three seats, got %d", len(g.Seats))
|
||||||
|
}
|
||||||
|
if g.Phase != "handover" {
|
||||||
|
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play a whole hand, then check that not one chip has crossed the border.
|
||||||
|
deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "deal"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("deal = %d, want 200", code)
|
||||||
|
}
|
||||||
|
for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ {
|
||||||
|
move := "check"
|
||||||
|
if deal.Holdem.Owed > 0 {
|
||||||
|
move = "fold"
|
||||||
|
}
|
||||||
|
deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": move}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("%s = %d, want 200", move, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deal.Chips != 4400 {
|
||||||
|
t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+
|
||||||
|
"a pot is settled inside the engine, not across the border", deal.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wall. A bot's hole cards are the game; a browser that held them would make
|
||||||
|
// this unplayable, and the payload is where that has to be true.
|
||||||
|
func TestHoldemNeverSendsABotsCards(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "micro", "bots": 3, "buyin": 200}))
|
||||||
|
if v.Holdem == nil {
|
||||||
|
t.Fatal("no table")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play hands until one of them ends without a showdown, checking every payload
|
||||||
|
// on the way. Folding is the case that matters: nobody has earned the right to
|
||||||
|
// see anybody's cards, so nobody's may be in there.
|
||||||
|
for hand := 0; hand < 8; hand++ {
|
||||||
|
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "deal"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("deal = %d", code)
|
||||||
|
}
|
||||||
|
noBotCards(t, v)
|
||||||
|
|
||||||
|
for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ {
|
||||||
|
move := "check"
|
||||||
|
if v.Holdem.Owed > 0 {
|
||||||
|
move = "call"
|
||||||
|
}
|
||||||
|
v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": move}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("%s = %d", move, code)
|
||||||
|
}
|
||||||
|
noBotCards(t, v)
|
||||||
|
}
|
||||||
|
if v.Holdem == nil || v.Holdem.Phase == "done" {
|
||||||
|
return // busted out; the wall held all the way
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noBotCards checks a payload. A bot's cards may appear in exactly one place: a
|
||||||
|
// seat that is being shown down, on a board that reached a showdown.
|
||||||
|
func noBotCards(t *testing.T, v tableView) {
|
||||||
|
t.Helper()
|
||||||
|
g := v.Holdem
|
||||||
|
if g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shown := g.Street == "showdown"
|
||||||
|
for i, seat := range g.Seats {
|
||||||
|
if i == 0 || len(seat.Cards) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !shown {
|
||||||
|
t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down",
|
||||||
|
i, seat.Name, len(seat.Cards), g.Street)
|
||||||
|
}
|
||||||
|
if seat.State == "folded" {
|
||||||
|
t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range v.HoldemEvents {
|
||||||
|
if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" {
|
||||||
|
t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getting up is what pays. Everything the session did lands in one movement.
|
||||||
|
func TestHoldemLeavingBringsTheStackBack(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
|
||||||
|
if v.Chips != 4500 || v.Holdem == nil {
|
||||||
|
t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem)
|
||||||
|
}
|
||||||
|
stack := v.Holdem.Stack
|
||||||
|
|
||||||
|
v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "leave"}))
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("leave = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if v.Chips != 4500+stack {
|
||||||
|
t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)",
|
||||||
|
v.Chips, 4500+stack, stack)
|
||||||
|
}
|
||||||
|
if v.Game != "" {
|
||||||
|
t.Errorf("still at a table after getting up: %q", v.Game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And there is no table left to play at.
|
||||||
|
_, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "deal"}))
|
||||||
|
if code != 409 {
|
||||||
|
t.Errorf("dealt a hand at a table we got up from: %d, want 409", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot walk out on a hand you have chips riding on.
|
||||||
|
func TestHoldemCannotLeaveMidHand(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
|
||||||
|
v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "deal"}))
|
||||||
|
if v.Holdem == nil || v.Holdem.Phase != "betting" {
|
||||||
|
t.Skip("the hand ended before we could act")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "leave"}))
|
||||||
|
if code != 400 {
|
||||||
|
t.Errorf("left in the middle of a hand: %d, want 400", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the chips are still on the table, not back on the stack.
|
||||||
|
after, err := storage.Chips("@reala:parodia.dev")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Chips != 4500 {
|
||||||
|
t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A buy-in outside the table's range is not a buy-in, and it must not take chips.
|
||||||
|
func TestHoldemRefusesABadBuyIn(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
tier, _ := holdem.TierBySlug("low")
|
||||||
|
for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} {
|
||||||
|
_, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 2, "buyin": amount}))
|
||||||
|
if code != 400 {
|
||||||
|
t.Errorf("buy-in of %d at a %d–%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := storage.Chips("@reala:parodia.dev")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Chips != 5000 {
|
||||||
|
t.Errorf("a refused buy-in took %d chips", 5000-st.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A top-up is real money crossing the border. If the engine refuses it, the chips
|
||||||
|
// come straight back — the same order, and the same reason, as doubling down.
|
||||||
|
func TestHoldemTopUpRefundsWhenRefused(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
fund(t, 5000)
|
||||||
|
|
||||||
|
// Sitting down at the maximum means there is no room to top up into.
|
||||||
|
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
|
||||||
|
map[string]any{"tier": "low", "bots": 1, "buyin": 1000}))
|
||||||
|
|
||||||
|
_, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move",
|
||||||
|
map[string]any{"move": "topup", "amount": 100}))
|
||||||
|
if code != 400 {
|
||||||
|
t.Errorf("topped up over the table maximum: %d, want 400", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := storage.Chips("@reala:parodia.dev")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Chips != 4000 {
|
||||||
|
t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/hangman"
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/holdem"
|
||||||
"pete/internal/games/klondike"
|
"pete/internal/games/klondike"
|
||||||
"pete/internal/games/trivia"
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/games/uno"
|
"pete/internal/games/uno"
|
||||||
@@ -28,9 +29,10 @@ type gameTeaser struct {
|
|||||||
Blurb string
|
Blurb string
|
||||||
}
|
}
|
||||||
|
|
||||||
var comingSoon = []gameTeaser{
|
// comingSoon is empty, and that is the point: every game the plan named is now on
|
||||||
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
// the felt. Leave it here — the lobby renders nothing for an empty list, and the
|
||||||
}
|
// next game to be dreamed up goes in it.
|
||||||
|
var comingSoon = []gameTeaser{}
|
||||||
|
|
||||||
// betDenominations are the chips you build a bet out of.
|
// betDenominations are the chips you build a bet out of.
|
||||||
var betDenominations = []int64{5, 25, 100, 500}
|
var betDenominations = []int64{5, 25, 100, 500}
|
||||||
@@ -78,6 +80,8 @@ type gamesPage struct {
|
|||||||
Quizzes []trivia.Tier // trivia's three difficulties
|
Quizzes []trivia.Tier // trivia's three difficulties
|
||||||
Rungs int // how long the trivia ladder is
|
Rungs int // how long the trivia ladder is
|
||||||
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
||||||
|
Stakes []holdem.Tier // hold'em's three tables, by blinds
|
||||||
|
MaxBots int // how many seats hold'em will fill with bots
|
||||||
}
|
}
|
||||||
|
|
||||||
// casinoRoutes hangs every table off the mux.
|
// casinoRoutes hangs every table off the mux.
|
||||||
@@ -93,6 +97,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||||
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||||
mux.HandleFunc("GET /games/uno", s.handleUno)
|
mux.HandleFunc("GET /games/uno", s.handleUno)
|
||||||
|
mux.HandleFunc("GET /games/holdem", s.handleHoldem)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||||
@@ -112,6 +117,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
|||||||
|
|
||||||
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
||||||
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
|
||||||
|
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||||
@@ -145,6 +153,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
|||||||
Quizzes: trivia.Tiers,
|
Quizzes: trivia.Tiers,
|
||||||
Rungs: trivia.Rungs,
|
Rungs: trivia.Rungs,
|
||||||
Tables: uno.Tiers,
|
Tables: uno.Tiers,
|
||||||
|
Stakes: holdem.Tiers,
|
||||||
|
MaxBots: holdem.MaxBots,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,3 +199,10 @@ func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.render(w, "uno", s.gamesPage(r))
|
s.render(w, "uno", s.gamesPage(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHoldem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requirePlayer(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, "holdem", s.gamesPage(r))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"pete/internal/games/blackjack"
|
"pete/internal/games/blackjack"
|
||||||
"pete/internal/games/cards"
|
"pete/internal/games/cards"
|
||||||
"pete/internal/games/hangman"
|
"pete/internal/games/hangman"
|
||||||
|
"pete/internal/games/holdem"
|
||||||
"pete/internal/games/klondike"
|
"pete/internal/games/klondike"
|
||||||
"pete/internal/games/trivia"
|
"pete/internal/games/trivia"
|
||||||
"pete/internal/games/uno"
|
"pete/internal/games/uno"
|
||||||
@@ -197,6 +198,9 @@ type tableView struct {
|
|||||||
Uno *unoView `json:"uno,omitempty"`
|
Uno *unoView `json:"uno,omitempty"`
|
||||||
UnoEvents []unoEventView `json:"uno_events,omitempty"`
|
UnoEvents []unoEventView `json:"uno_events,omitempty"`
|
||||||
|
|
||||||
|
Holdem *holdemView `json:"holdem,omitempty"`
|
||||||
|
HoldemEvents []holdemEventView `json:"holdem_events,omitempty"`
|
||||||
|
|
||||||
Rake float64 `json:"rake_pct"`
|
Rake float64 `json:"rake_pct"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +268,13 @@ func (s *Server) table(user string) (tableView, error) {
|
|||||||
}
|
}
|
||||||
uv := viewUno(g)
|
uv := viewUno(g)
|
||||||
v.Uno = &uv
|
v.Uno = &uv
|
||||||
|
case gameHoldem:
|
||||||
|
var g holdem.State
|
||||||
|
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||||
|
return s.dropUnreadable(user, v, err)
|
||||||
|
}
|
||||||
|
hv := viewHoldem(g)
|
||||||
|
v.Holdem = &hv
|
||||||
default:
|
default:
|
||||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||||
}
|
}
|
||||||
@@ -521,6 +532,7 @@ const (
|
|||||||
gameSolitaire = "solitaire"
|
gameSolitaire = "solitaire"
|
||||||
gameTrivia = "trivia"
|
gameTrivia = "trivia"
|
||||||
gameUno = "uno"
|
gameUno = "uno"
|
||||||
|
gameHoldem = "holdem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// finished is what commit needs to know about a game it's writing back: enough
|
// finished is what commit needs to know about a game it's writing back: enough
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func TestEveryCasinoPageRenders(t *testing.T) {
|
|||||||
"/games/solitaire",
|
"/games/solitaire",
|
||||||
"/games/trivia",
|
"/games/trivia",
|
||||||
"/games/uno",
|
"/games/uno",
|
||||||
|
"/games/holdem",
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno"}},
|
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||||
}
|
}
|
||||||
tpls := make(map[string]*template.Template)
|
tpls := make(map[string]*template.Template)
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
|
|||||||
@@ -1894,3 +1894,257 @@ html[data-room] .pete-felt {
|
|||||||
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
|
.cs-stack[data-chip="25"] { --chip: #4caf7d; }
|
||||||
.cs-stack[data-chip="100"] { --chip: #2b2118; }
|
.cs-stack[data-chip="100"] { --chip: #2b2118; }
|
||||||
.cs-stack[data-chip="500"] { --chip: #b079d6; }
|
.cs-stack[data-chip="500"] { --chip: #b079d6; }
|
||||||
|
|
||||||
|
/* ── Hold'em ────────────────────────────────────────────────────────────────
|
||||||
|
The one table with other people at it. Everything else in the casino has a
|
||||||
|
single bet spot, because there was only ever one player; here every seat has
|
||||||
|
its own, chips travel between them and the pot rather than between you and the
|
||||||
|
house, and the pot in the middle is the thing they all move toward.
|
||||||
|
|
||||||
|
Seats are a wrapping row rather than an ellipse. An oval of six seats is what a
|
||||||
|
poker table looks like, and it is also what stops fitting the moment a phone is
|
||||||
|
held up — this keeps the geometry honest at 390px and still reads as a table
|
||||||
|
because the board and the pot sit in the middle of it, which is where the eye
|
||||||
|
goes anyway. */
|
||||||
|
|
||||||
|
.pete-poker { --seat-w: 7.5rem; }
|
||||||
|
|
||||||
|
.pete-poker-seats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-seat {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: var(--seat-w);
|
||||||
|
transition: opacity 0.3s ease, filter 0.3s ease;
|
||||||
|
}
|
||||||
|
/* A folded seat is still there — you can see they're in the game and out of the
|
||||||
|
hand, which is information you're entitled to. It just stops competing. */
|
||||||
|
.pete-seat[data-state="folded"] { opacity: 0.38; filter: grayscale(0.7); }
|
||||||
|
.pete-seat[data-state="out"] { opacity: 0.2; }
|
||||||
|
|
||||||
|
/* Whose turn it is. The ring is the only thing on the felt that moves on its own,
|
||||||
|
because it is the only thing you are waiting for. */
|
||||||
|
.pete-seat[data-turn="1"] .pete-seat-plate {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent), 0 0 1.6rem -0.2rem var(--accent);
|
||||||
|
animation: pete-seat-wait 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pete-seat-wait {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-seat-cards {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
--card-h: 4.4rem;
|
||||||
|
--card-w: 3.15rem;
|
||||||
|
}
|
||||||
|
/* A seat that folded throws its cards in. */
|
||||||
|
.pete-seat-cards[data-mucked="1"] { opacity: 0; transform: translateY(-0.6rem) scale(0.9); transition: all 0.35s ease; }
|
||||||
|
|
||||||
|
.pete-seat-plate {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgba(0,0,0,0.32);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
transition: box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
.pete-seat-name {
|
||||||
|
font-family: var(--font-display, inherit);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pete-seat-stack {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(255,255,255,0.58);
|
||||||
|
}
|
||||||
|
.pete-seat[data-state="allin"] .pete-seat-stack::after {
|
||||||
|
content: " all in";
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dealer button, and the blinds. Small, and worth having: position is most of
|
||||||
|
what makes one hand different from the next, and a player who cannot see where
|
||||||
|
the button is cannot see the game. */
|
||||||
|
.pete-seat-pos {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
right: -0.5rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
color: #2b2118;
|
||||||
|
box-shadow: 0 2px 0 rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.pete-seat-pos[data-pos="BTN"] { background: #f5d76e; }
|
||||||
|
|
||||||
|
/* Every seat's bet, sitting between them and the pot.
|
||||||
|
A .pete-spot is 7rem across, because blackjack has exactly one of them. Six of
|
||||||
|
those is most of a felt, so a seat's is less than half the size — and so are the
|
||||||
|
chips in it, which are otherwise bigger than the ring they land in. */
|
||||||
|
.pete-seat-spot {
|
||||||
|
height: 3.6rem;
|
||||||
|
width: 3.6rem;
|
||||||
|
margin-bottom: 0.5rem; /* the bet total hangs below the ring; leave it somewhere to hang */
|
||||||
|
}
|
||||||
|
.pete-seat-spot .pete-disc {
|
||||||
|
height: 1.6rem;
|
||||||
|
width: 1.6rem;
|
||||||
|
margin: -0.8rem 0 0 -0.8rem;
|
||||||
|
}
|
||||||
|
.pete-seat-spot .pete-spot-total {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The middle of the table: the board, and the pot under it. */
|
||||||
|
.pete-poker-middle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
/* The pot's chips stack upwards, so they need room under the board or they climb
|
||||||
|
into the river card. */
|
||||||
|
.pete-poker-pot { margin-top: 0.9rem; }
|
||||||
|
/* The board keeps its height when it is empty, so the felt does not jump a hundred
|
||||||
|
pixels the moment the flop lands. */
|
||||||
|
.pete-poker-board {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 6rem;
|
||||||
|
--card-h: 6rem;
|
||||||
|
--card-w: 4.3rem;
|
||||||
|
}
|
||||||
|
.pete-poker-pot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
/* The pot's chips need a box to themselves. .pete-stack is position:absolute with
|
||||||
|
inset:0, so a pile that shares a box with the number under it lands on top of
|
||||||
|
the number — which is exactly what it did: the pot showed a chip and no total. */
|
||||||
|
.pete-poker-pot-pile {
|
||||||
|
position: relative;
|
||||||
|
width: 7rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
.pete-poker-pot-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255,255,255,0.42);
|
||||||
|
}
|
||||||
|
.pete-poker-pot-total {
|
||||||
|
font-family: var(--font-display, inherit);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 0 rgba(0,0,0,0.28);
|
||||||
|
}
|
||||||
|
.pete-poker-side {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your seat. Bigger cards, because they are the two you are actually reading. */
|
||||||
|
.pete-poker-you .pete-seat { width: auto; }
|
||||||
|
.pete-poker-you .pete-seat-cards {
|
||||||
|
--card-h: 6.8rem;
|
||||||
|
--card-w: 4.85rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 6.8rem;
|
||||||
|
}
|
||||||
|
.pete-poker-you .pete-seat-plate { padding: 0.4rem 1.1rem; }
|
||||||
|
.pete-poker-you .pete-seat-name { font-size: 0.95rem; }
|
||||||
|
.pete-poker-you .pete-seat-stack { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
/* What the hand did to you, said once, in the middle of the felt. */
|
||||||
|
.pete-poker-verdict {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
font-family: var(--font-display, inherit);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2b2118;
|
||||||
|
box-shadow: 0 4px 0 rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.pete-poker-verdict[data-tone="win"] { background: #4caf7d; color: #fff; }
|
||||||
|
.pete-poker-verdict[data-tone="lose"] { background: rgba(255,255,255,0.9); color: #7a5c50; }
|
||||||
|
|
||||||
|
/* The action bar. Raise is a slider, because a raise is a *size* and a text box
|
||||||
|
makes you type a number you have not thought about. */
|
||||||
|
.pete-raise {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pete-raise input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.pete-raise-to {
|
||||||
|
font-family: var(--font-display, inherit);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 3.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pete-poker-log {
|
||||||
|
max-height: 7rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.pete-poker-log b { color: rgba(255,255,255,0.85); font-weight: 700; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pete-poker { --seat-w: 5.6rem; }
|
||||||
|
.pete-seat-cards { --card-h: 3.5rem; --card-w: 2.5rem; min-height: 3.5rem; }
|
||||||
|
.pete-poker-board { --card-h: 5rem; --card-w: 3.55rem; min-height: 5rem; gap: 0.25rem; }
|
||||||
|
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
||||||
|
.pete-poker-pot-total { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
659
internal/web/static/js/holdem.js
Normal file
659
internal/web/static/js/holdem.js
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
// The hold'em table.
|
||||||
|
//
|
||||||
|
// Same bargain as every other table in the room: the browser holds no game. It
|
||||||
|
// sends one move, and what comes back is that move *and every bot action behind
|
||||||
|
// it* — plus whatever streets that finished and whatever the pot did — as a
|
||||||
|
// script of events. So a round trip here can be a whole hand: shove all-in and
|
||||||
|
// the flop, turn, river, showdown and payout all arrive in one response, and this
|
||||||
|
// file's job is to play them back slowly enough that you can watch it happen to
|
||||||
|
// you.
|
||||||
|
//
|
||||||
|
// The one thing here that no other table has is a *second seat with money on it*.
|
||||||
|
// Everywhere else the spot is a singleton, because there was only ever you and
|
||||||
|
// the house. Here every seat has its own, chips move from a seat to its spot and
|
||||||
|
// from every spot into the pot, and out of the pot to whoever won it. PeteFX.spot
|
||||||
|
// still owns the rule that the number under a pile is a readout of that pile, so
|
||||||
|
// none of that arithmetic lives in here.
|
||||||
|
//
|
||||||
|
// And the browser never learns a bot's hand. Their cards are backs until a
|
||||||
|
// showdown turns them over, because a showdown is the only time a player at a
|
||||||
|
// real table would be looking at them.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.querySelector("[data-holdem]");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var FX = window.PeteFX;
|
||||||
|
var Cards = window.PeteCards;
|
||||||
|
|
||||||
|
var seatsEl = root.querySelector("[data-seats]");
|
||||||
|
var youEl = root.querySelector("[data-you]");
|
||||||
|
var boardEl = root.querySelector("[data-board]");
|
||||||
|
var potStack = root.querySelector("[data-pot-stack]");
|
||||||
|
var potTotal = root.querySelector("[data-pot-total]");
|
||||||
|
var sideEl = root.querySelector("[data-side]");
|
||||||
|
var blindsEl = root.querySelector("[data-blinds]");
|
||||||
|
var tableName = root.querySelector("[data-table-name]");
|
||||||
|
var verdictEl = root.querySelector("[data-verdict]");
|
||||||
|
var houseEl = root.querySelector("[data-house]");
|
||||||
|
|
||||||
|
var acting = root.querySelector("[data-acting]");
|
||||||
|
var between = root.querySelector("[data-between]");
|
||||||
|
var sitting = root.querySelector("[data-sitting]");
|
||||||
|
|
||||||
|
var foldBtn = root.querySelector('[data-move="fold"]');
|
||||||
|
var checkBtn = root.querySelector('[data-move="check"]');
|
||||||
|
var callBtn = root.querySelector('[data-move="call"]');
|
||||||
|
var raiseBtn = root.querySelector('[data-move="raise"]');
|
||||||
|
var callAmt = root.querySelector("[data-call-amount]");
|
||||||
|
var raiseRow = root.querySelector("[data-raise-row]");
|
||||||
|
var slider = root.querySelector("[data-raise-slider]");
|
||||||
|
var raiseTo = root.querySelector("[data-raise-to]");
|
||||||
|
var raiseLbl = root.querySelector("[data-raise-label]");
|
||||||
|
var raiseVerb = root.querySelector("[data-raise-verb]");
|
||||||
|
|
||||||
|
var dealBtn = root.querySelector("[data-deal]");
|
||||||
|
var topupBtn = root.querySelector("[data-topup]");
|
||||||
|
var leaveBtn = root.querySelector("[data-leave]");
|
||||||
|
var stackEl = root.querySelector("[data-table-stack]");
|
||||||
|
var boughtEl = root.querySelector("[data-bought-in]");
|
||||||
|
var rakeEl = root.querySelector("[data-session-rake]");
|
||||||
|
|
||||||
|
var sitBtn = root.querySelector("[data-sit]");
|
||||||
|
var buySlider = root.querySelector("[data-buyin-slider]");
|
||||||
|
var buyLabel = root.querySelector("[data-buyin]");
|
||||||
|
var buyNote = root.querySelector("[data-buyin-note]");
|
||||||
|
var botsNote = root.querySelector("[data-bots-note]");
|
||||||
|
var gameMsg = root.querySelector("[data-game-msg]");
|
||||||
|
var betweenMsg = root.querySelector("[data-between-msg]");
|
||||||
|
var tableMsg = root.querySelector("[data-table-msg]");
|
||||||
|
|
||||||
|
var view = null; // the table, as the server last described it
|
||||||
|
var busy = false;
|
||||||
|
var seatEls = []; // one per seat: { root, cards, plate, stack, spot }
|
||||||
|
var shown = []; // what each seat's stack label currently reads
|
||||||
|
var pot = null; // the middle pile, a PeteFX.spot
|
||||||
|
|
||||||
|
var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]"));
|
||||||
|
var botBtns = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]"));
|
||||||
|
var tier = null;
|
||||||
|
var bots = 2;
|
||||||
|
var buyIn = 0;
|
||||||
|
|
||||||
|
function money(n) { return (n || 0).toLocaleString(); }
|
||||||
|
|
||||||
|
function say(el, text, tone) {
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) { el.classList.add("hidden"); return; }
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- building the felt ----------------------------------------------------
|
||||||
|
|
||||||
|
// seat builds one player: their cards, their name and stack, and the spot their
|
||||||
|
// bet sits on. Bots go in the row along the top; you get your own, bigger.
|
||||||
|
function seat(s, i, mine) {
|
||||||
|
var wrap = document.createElement("div");
|
||||||
|
wrap.className = "pete-seat";
|
||||||
|
wrap.dataset.seat = i;
|
||||||
|
wrap.dataset.state = s.state;
|
||||||
|
|
||||||
|
var cards = document.createElement("div");
|
||||||
|
cards.className = "pete-seat-cards";
|
||||||
|
|
||||||
|
var plate = document.createElement("div");
|
||||||
|
plate.className = "pete-seat-plate";
|
||||||
|
var name = document.createElement("span");
|
||||||
|
name.className = "pete-seat-name";
|
||||||
|
name.textContent = s.name;
|
||||||
|
var stack = document.createElement("span");
|
||||||
|
stack.className = "pete-seat-stack";
|
||||||
|
stack.textContent = money(s.stack);
|
||||||
|
plate.appendChild(name);
|
||||||
|
plate.appendChild(stack);
|
||||||
|
|
||||||
|
// The button, the blinds. It hangs off the name plate rather than the seat,
|
||||||
|
// because the seat's corner is a different place for you than for a bot — your
|
||||||
|
// bet spot is above your cards and theirs is below — and a badge that floats
|
||||||
|
// over an empty betting circle reads as a bug.
|
||||||
|
if (s.pos) {
|
||||||
|
var pos = document.createElement("span");
|
||||||
|
pos.className = "pete-seat-pos";
|
||||||
|
pos.dataset.pos = s.pos;
|
||||||
|
pos.textContent = s.pos;
|
||||||
|
plate.appendChild(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotEl = document.createElement("div");
|
||||||
|
spotEl.className = "pete-spot pete-seat-spot";
|
||||||
|
var pile = document.createElement("div");
|
||||||
|
pile.className = "pete-stack";
|
||||||
|
var total = document.createElement("span");
|
||||||
|
// The shared class, not one of our own: it hangs the number *below* the ring,
|
||||||
|
// which is what keeps the chips from landing on top of it.
|
||||||
|
total.className = "pete-spot-total";
|
||||||
|
spotEl.appendChild(pile);
|
||||||
|
spotEl.appendChild(total);
|
||||||
|
|
||||||
|
// Your bet sits between you and the board, so it goes above your cards; a
|
||||||
|
// bot's sits between them and the board, so it goes below theirs.
|
||||||
|
if (mine) {
|
||||||
|
wrap.appendChild(spotEl);
|
||||||
|
wrap.appendChild(cards);
|
||||||
|
wrap.appendChild(plate);
|
||||||
|
} else {
|
||||||
|
wrap.appendChild(cards);
|
||||||
|
wrap.appendChild(plate);
|
||||||
|
wrap.appendChild(spotEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = FX.spot({ spot: spotEl, stack: pile, total: total });
|
||||||
|
api.render(s.bet);
|
||||||
|
|
||||||
|
paintCards(cards, s, mine);
|
||||||
|
return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api };
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintCards puts the two cards in front of a seat. A bot's are backs, unless
|
||||||
|
// the server has actually sent us faces — which it only ever does at a showdown.
|
||||||
|
function paintCards(el, s, mine) {
|
||||||
|
el.innerHTML = "";
|
||||||
|
if (s.state === "out") return;
|
||||||
|
var faces = s.cards || [];
|
||||||
|
var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2);
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(v) {
|
||||||
|
view = v;
|
||||||
|
|
||||||
|
// The seats along the top, and you underneath.
|
||||||
|
seatsEl.innerHTML = "";
|
||||||
|
youEl.innerHTML = "";
|
||||||
|
seatEls = [];
|
||||||
|
shown = [];
|
||||||
|
v.seats.forEach(function (s, i) {
|
||||||
|
var mine = i === 0;
|
||||||
|
var built = seat(s, i, mine);
|
||||||
|
seatEls[i] = built;
|
||||||
|
shown[i] = s.stack;
|
||||||
|
(mine ? youEl : seatsEl).appendChild(built.root);
|
||||||
|
built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0";
|
||||||
|
});
|
||||||
|
|
||||||
|
// The board.
|
||||||
|
boardEl.innerHTML = "";
|
||||||
|
(v.board || []).forEach(function (c) {
|
||||||
|
boardEl.appendChild(Cards.el(c, { deal: false, tilt: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pot. Its chips live in a box of their own — see .pete-poker-pot-pile —
|
||||||
|
// so the number underneath stays readable.
|
||||||
|
pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null });
|
||||||
|
pot.render(v.pot);
|
||||||
|
potTotal.textContent = money(v.pot);
|
||||||
|
blindsEl.textContent = v.tier.sb + "/" + v.tier.bb;
|
||||||
|
tableName.textContent = v.tier.name;
|
||||||
|
if (v.side && v.side.length > 1) {
|
||||||
|
sideEl.textContent = v.side.map(function (n, i) {
|
||||||
|
return (i === 0 ? "main " : "side ") + money(n);
|
||||||
|
}).join(" · ");
|
||||||
|
sideEl.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
sideEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
stackEl.textContent = money(v.stack);
|
||||||
|
boughtEl.textContent = money(v.bought_in);
|
||||||
|
rakeEl.textContent = money(v.rake);
|
||||||
|
|
||||||
|
panels();
|
||||||
|
}
|
||||||
|
|
||||||
|
// panels decides which of the three bars is showing: the one that acts, the one
|
||||||
|
// between hands, or the one you sit down from.
|
||||||
|
function panels() {
|
||||||
|
// A session you have got up from is not a live one: the felt still shows the
|
||||||
|
// last hand, but the table you sit down at is the one that's open to you.
|
||||||
|
var live = !!view && view.phase !== "done";
|
||||||
|
sitting.classList.toggle("hidden", live);
|
||||||
|
acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== 0);
|
||||||
|
between.classList.toggle("hidden", !live || view.phase !== "handover");
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
if (view.phase === "betting" && view.to_act === 0) {
|
||||||
|
checkBtn.classList.toggle("hidden", !view.can_check);
|
||||||
|
callBtn.classList.toggle("hidden", view.can_check);
|
||||||
|
callAmt.textContent = money(view.owed);
|
||||||
|
|
||||||
|
raiseRow.classList.toggle("hidden", !view.can_raise);
|
||||||
|
if (view.can_raise) {
|
||||||
|
slider.min = view.min_raise_to;
|
||||||
|
slider.max = view.max_raise_to;
|
||||||
|
slider.step = Math.max(1, view.tier.bb / 2);
|
||||||
|
slider.value = Math.min(view.max_raise_to, view.min_raise_to);
|
||||||
|
// "Bet" when nobody has, "Raise to" when somebody has. It is the same
|
||||||
|
// move and the same button, but calling a bet a raise is how you tell a
|
||||||
|
// player who has never played that this table is confused.
|
||||||
|
raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet";
|
||||||
|
showRaise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (view.phase === "handover") {
|
||||||
|
// The table has room for max_topup, but the button must not promise chips the
|
||||||
|
// wallet cannot cover — clicking it would only earn a refusal.
|
||||||
|
var wallet = window.PeteGames.view();
|
||||||
|
var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
|
||||||
|
topupBtn.disabled = can <= 0;
|
||||||
|
topupBtn.dataset.amount = can;
|
||||||
|
topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRaise() {
|
||||||
|
var to = Number(slider.value);
|
||||||
|
raiseTo.textContent = money(to);
|
||||||
|
raiseLbl.textContent = money(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- playing the script ---------------------------------------------------
|
||||||
|
|
||||||
|
var STREETS = { flop: 1, turn: 1, river: 1 };
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// play walks the events the server sent, one beat at a time, and only then
|
||||||
|
// re-renders the table it ended on. Everything the player is meant to see
|
||||||
|
// happening happens here; render() is the state it settles into.
|
||||||
|
function play(events, final) {
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
// No table to settle into — the session closed and storage has already cleared
|
||||||
|
// it. There is nothing to animate onto, and render() would walk seats that
|
||||||
|
// aren't there.
|
||||||
|
if (!final) { render0(); return Promise.resolve(); }
|
||||||
|
if (!events || !events.length) { render(final); return chain; }
|
||||||
|
|
||||||
|
events.forEach(function (e) {
|
||||||
|
chain = chain.then(function () { return beat(e, final); });
|
||||||
|
});
|
||||||
|
return chain.then(function () {
|
||||||
|
render(final);
|
||||||
|
verdict(events, final);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function beat(e, final) {
|
||||||
|
var s = seatEls[e.seat];
|
||||||
|
|
||||||
|
switch (e.kind) {
|
||||||
|
case "hand":
|
||||||
|
// A new deal: clear the felt before anything lands on it.
|
||||||
|
boardEl.innerHTML = "";
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; });
|
||||||
|
pot.render(0);
|
||||||
|
potTotal.textContent = "0";
|
||||||
|
sideEl.classList.add("hidden");
|
||||||
|
return wait(140);
|
||||||
|
|
||||||
|
case "rebuy":
|
||||||
|
if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); }
|
||||||
|
return wait(220);
|
||||||
|
|
||||||
|
case "blind":
|
||||||
|
if (!s) return;
|
||||||
|
moveStack(e.seat, -e.amount);
|
||||||
|
return s.spot.pour(s.plate, e.amount);
|
||||||
|
|
||||||
|
case "hole":
|
||||||
|
// Two cards to everybody, round the table, as they are actually dealt.
|
||||||
|
return dealHoles(final);
|
||||||
|
|
||||||
|
case "action":
|
||||||
|
return action(e, final);
|
||||||
|
|
||||||
|
case "flop":
|
||||||
|
case "turn":
|
||||||
|
case "river":
|
||||||
|
return street(e);
|
||||||
|
|
||||||
|
case "pot":
|
||||||
|
// The bets in front of the seats have been swept in. Nothing else in the
|
||||||
|
// script says so, and the pot is about to be paid out of.
|
||||||
|
return collect(e.amount);
|
||||||
|
|
||||||
|
case "uncalled":
|
||||||
|
if (!s) return;
|
||||||
|
return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); });
|
||||||
|
|
||||||
|
case "show":
|
||||||
|
if (!s) return;
|
||||||
|
paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === 0);
|
||||||
|
flash(s.root);
|
||||||
|
return wait(420);
|
||||||
|
|
||||||
|
case "rake":
|
||||||
|
// The house takes its cut out of the pot, in front of you, so it is a
|
||||||
|
// thing that visibly happens rather than a number that quietly differs.
|
||||||
|
return pot.sweep(houseEl, e.amount).then(function () {
|
||||||
|
potTotal.textContent = money(pot.amount);
|
||||||
|
return wait(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
case "win":
|
||||||
|
if (!s) return;
|
||||||
|
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
|
||||||
|
potTotal.textContent = money(pot.amount);
|
||||||
|
moveStack(e.seat, e.amount);
|
||||||
|
if (e.seat === 0 && e.amount > 0) FX.burst(s.plate, { count: 18 });
|
||||||
|
return wait(260);
|
||||||
|
});
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
return wait(280);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// dealHoles puts two cards in front of everyone still in the hand. Yours land
|
||||||
|
// face up; theirs land as backs, because that is all that came over the wire.
|
||||||
|
function dealHoles(final) {
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
for (var round = 0; round < 2; round++) {
|
||||||
|
(function (round) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
var beats = [];
|
||||||
|
final.seats.forEach(function (s, i) {
|
||||||
|
if (s.state === "out") return;
|
||||||
|
var built = seatEls[i];
|
||||||
|
if (!built) return;
|
||||||
|
var face = (i === 0 && s.cards) ? s.cards[round] : null;
|
||||||
|
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
|
||||||
|
built.cards.appendChild(card);
|
||||||
|
beats.push(wait(70 * i));
|
||||||
|
});
|
||||||
|
return Promise.all(beats).then(function () { return wait(180); });
|
||||||
|
});
|
||||||
|
})(round);
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// action animates one seat doing one thing.
|
||||||
|
function action(e, final) {
|
||||||
|
var s = seatEls[e.seat];
|
||||||
|
if (!s) return Promise.resolve();
|
||||||
|
|
||||||
|
if (e.text === "fold") {
|
||||||
|
s.root.dataset.state = "folded";
|
||||||
|
s.cards.dataset.mucked = "1";
|
||||||
|
return wait(320);
|
||||||
|
}
|
||||||
|
if (e.text === "check") {
|
||||||
|
flash(s.root);
|
||||||
|
return wait(320);
|
||||||
|
}
|
||||||
|
// call, raise, allin: chips leave their stack for their spot.
|
||||||
|
if (!e.amount) return wait(200);
|
||||||
|
moveStack(e.seat, -e.amount);
|
||||||
|
return s.spot.pour(s.plate, e.amount).then(function () {
|
||||||
|
if (e.text === "allin") flash(s.root);
|
||||||
|
return wait(180);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect sweeps every seat's bet into the middle. The total is worked out up
|
||||||
|
// front rather than accumulated as the chips land, because the sweeps run at the
|
||||||
|
// same time and would otherwise race each other into the pot's counter.
|
||||||
|
function collect(total) {
|
||||||
|
var moved = 0;
|
||||||
|
var sweeps = seatEls.map(function (s) {
|
||||||
|
if (!s || s.spot.amount <= 0) return Promise.resolve();
|
||||||
|
moved += s.spot.amount;
|
||||||
|
return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 });
|
||||||
|
});
|
||||||
|
if (!moved) {
|
||||||
|
if (total != null) { pot.render(total); potTotal.textContent = money(total); }
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
var to = total != null ? total : pot.amount + moved;
|
||||||
|
return Promise.all(sweeps).then(function () {
|
||||||
|
pot.render(to);
|
||||||
|
potTotal.textContent = money(to);
|
||||||
|
return wait(200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// street sweeps the bets in, then turns the cards.
|
||||||
|
function street(e) {
|
||||||
|
return collect(e.amount).then(function () {
|
||||||
|
// The board turns one card at a time, even the flop. Three cards appearing
|
||||||
|
// at once is a screenshot; three cards appearing in a row is a flop.
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
(e.cards || []).forEach(function (c) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
|
||||||
|
return wait(240);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}).then(function () {
|
||||||
|
return wait(200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveStack keeps a seat's stack label honest *while the chips are moving*. The
|
||||||
|
// authoritative number is always the server's, and render() puts it back at the
|
||||||
|
// end of the script — but a stack that only updates then would sit unchanged
|
||||||
|
// through the whole hand and then jump, which reads as the table correcting
|
||||||
|
// itself rather than as chips being paid.
|
||||||
|
function moveStack(i, delta) {
|
||||||
|
var s = seatEls[i];
|
||||||
|
if (!s) return;
|
||||||
|
shown[i] = Math.max(0, (shown[i] || 0) + delta);
|
||||||
|
FX.count(s.stackEl, shown[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(el) {
|
||||||
|
el.animate(
|
||||||
|
[{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }],
|
||||||
|
{ duration: 320, easing: "ease-out" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdict says what the hand did to you, once, in words.
|
||||||
|
function verdict(events, final) {
|
||||||
|
var won = 0, showed = false, busted = false;
|
||||||
|
events.forEach(function (e) {
|
||||||
|
if (e.kind === "win" && e.seat === 0) won += e.amount;
|
||||||
|
if (e.kind === "show") showed = true;
|
||||||
|
if (e.kind === "bust") busted = true;
|
||||||
|
});
|
||||||
|
if (busted) {
|
||||||
|
show("You're out of chips. Sit down again when you're ready.", "lose");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!events.some(function (e) { return e.kind === "end"; })) return;
|
||||||
|
|
||||||
|
var me = final.seats[0];
|
||||||
|
if (won > 0) {
|
||||||
|
show(showed
|
||||||
|
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
||||||
|
: "They folded. You take " + money(won) + ".", "win");
|
||||||
|
} else if (me.state === "folded") {
|
||||||
|
show("Folded.", "lose");
|
||||||
|
} else {
|
||||||
|
show("No good this time.", "lose");
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(text, tone) {
|
||||||
|
verdictEl.textContent = text;
|
||||||
|
verdictEl.dataset.tone = tone;
|
||||||
|
verdictEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handName(events) {
|
||||||
|
var mine = events.filter(function (e) { return e.kind === "show" && e.seat === 0; })[0];
|
||||||
|
return mine && mine.text ? mine.text : "the best hand";
|
||||||
|
}
|
||||||
|
|
||||||
|
// "You win 975 with straight" is not a sentence. Most hands take an article and
|
||||||
|
// the counted ones don't.
|
||||||
|
function article(desc) {
|
||||||
|
if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc;
|
||||||
|
return "a " + desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- talking to the table --------------------------------------------------
|
||||||
|
|
||||||
|
function send(body, msgEl) {
|
||||||
|
if (busy) return Promise.resolve();
|
||||||
|
busy = true;
|
||||||
|
say(msgEl, "");
|
||||||
|
// Whatever the last hand said about itself stops being true the moment you do
|
||||||
|
// something. Only the "hand" beat used to clear this, so a verdict could linger
|
||||||
|
// over a hand it had nothing to do with.
|
||||||
|
verdictEl.classList.add("hidden");
|
||||||
|
return window.PeteGames
|
||||||
|
.post("/api/games/holdem/move", body)
|
||||||
|
.then(function (v) {
|
||||||
|
window.PeteGames.apply(v);
|
||||||
|
return play(v.holdem_events, v.holdem || null).then(function () {
|
||||||
|
if (!v.holdem) { render0(); return; }
|
||||||
|
if (v.holdem.phase === "done") {
|
||||||
|
var got = v.holdem.payout, put = v.holdem.bought_in;
|
||||||
|
say(tableMsg, got > put
|
||||||
|
? "You got up " + money(got - put) + " ahead. " + money(got) + " back on your stack."
|
||||||
|
: got === 0
|
||||||
|
? "Cleaned out. Better luck at the next table."
|
||||||
|
: "You got up " + money(put - got) + " down. " + money(got) + " back on your stack.");
|
||||||
|
setTimeout(render0, 2600);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (err) { say(msgEl, err.message, "bad"); })
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// render0 is the table with nobody at it.
|
||||||
|
function render0() {
|
||||||
|
view = null;
|
||||||
|
seatsEl.innerHTML = "";
|
||||||
|
youEl.innerHTML = "";
|
||||||
|
boardEl.innerHTML = "";
|
||||||
|
potStack.innerHTML = "";
|
||||||
|
potTotal.textContent = "0";
|
||||||
|
sideEl.classList.add("hidden");
|
||||||
|
panels();
|
||||||
|
syncSit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldBtn) foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
|
||||||
|
if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); });
|
||||||
|
if (callBtn) callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); });
|
||||||
|
if (raiseBtn) raiseBtn.addEventListener("click", function () {
|
||||||
|
var to = Number(slider.value);
|
||||||
|
// Sliding all the way to the top is shoving, and the table would rather be
|
||||||
|
// told that than be told to raise to exactly everything you have.
|
||||||
|
send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slider) slider.addEventListener("input", showRaise);
|
||||||
|
root.querySelectorAll("[data-raise-preset]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
var which = b.dataset.raisePreset;
|
||||||
|
var to;
|
||||||
|
// A pot-sized raise is: call what's owed, then bet what the pot would then be.
|
||||||
|
// So the total is twice what you owe, plus the pot as it stands.
|
||||||
|
if (which === "max") to = view.max_raise_to;
|
||||||
|
else to = 2 * view.owed + view.pot * Number(which);
|
||||||
|
to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
|
||||||
|
slider.value = to;
|
||||||
|
showRaise();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
||||||
|
if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); });
|
||||||
|
if (topupBtn) topupBtn.addEventListener("click", function () {
|
||||||
|
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- sitting down ----------------------------------------------------------
|
||||||
|
|
||||||
|
function pickTier(btn) {
|
||||||
|
tier = btn;
|
||||||
|
tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
||||||
|
var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb);
|
||||||
|
buySlider.min = min;
|
||||||
|
buySlider.max = max;
|
||||||
|
buySlider.step = bb;
|
||||||
|
buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks
|
||||||
|
syncSit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBots(btn) {
|
||||||
|
bots = Number(btn.dataset.botCount);
|
||||||
|
botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
|
||||||
|
syncSit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSit() {
|
||||||
|
if (!tier) return;
|
||||||
|
buyIn = Number(buySlider.value);
|
||||||
|
var bb = Number(tier.dataset.bb);
|
||||||
|
buyLabel.textContent = money(buyIn);
|
||||||
|
buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them.";
|
||||||
|
botsNote.textContent = bots === 1
|
||||||
|
? "Heads up. The bots know this game best when there's only one of them."
|
||||||
|
: bots + " bots. More opponents, and a hand has to be better to be worth playing.";
|
||||||
|
|
||||||
|
var chips = window.PeteGames.view();
|
||||||
|
sitBtn.disabled = !chips || chips.chips < buyIn;
|
||||||
|
say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); });
|
||||||
|
botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); });
|
||||||
|
if (buySlider) buySlider.addEventListener("input", syncSit);
|
||||||
|
|
||||||
|
if (sitBtn) sitBtn.addEventListener("click", function () {
|
||||||
|
if (busy || !tier) return;
|
||||||
|
busy = true;
|
||||||
|
say(tableMsg, "");
|
||||||
|
window.PeteGames
|
||||||
|
.post("/api/games/holdem/sit", {
|
||||||
|
tier: tier.dataset.tier,
|
||||||
|
bots: bots,
|
||||||
|
buyin: Number(buySlider.value),
|
||||||
|
})
|
||||||
|
.then(function (v) {
|
||||||
|
window.PeteGames.apply(v);
|
||||||
|
render(v.holdem);
|
||||||
|
// A table with nobody dealt in yet is a table waiting for you to say go.
|
||||||
|
say(betweenMsg, "You're in. Deal when you're ready.");
|
||||||
|
})
|
||||||
|
.catch(function (err) { say(tableMsg, err.message, "bad"); })
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- boot ------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.PeteGames.onUpdate(function () { if (!view) syncSit(); });
|
||||||
|
|
||||||
|
pickTier(tierBtns[0]);
|
||||||
|
pickBots(botBtns[1]);
|
||||||
|
|
||||||
|
window.PeteGames.refresh().then(function (v) {
|
||||||
|
if (v && v.holdem) render(v.holdem);
|
||||||
|
else render0();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -106,6 +106,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/games/holdem"
|
||||||
|
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">♠️</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="font-display text-xl font-bold">Texas Hold'em</h3>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/60">Buy in. Beat them. Get up.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||||
|
A real cash game against bots that were trained on it, not scripted. No multiple, no
|
||||||
|
3:2 — you leave with whatever is in front of you, less the rake on the pots you win.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
{{range .Soon}}
|
{{range .Soon}}
|
||||||
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
|
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
212
internal/web/templates/holdem.html
Normal file
212
internal/web/templates/holdem.html
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
{{define "title"}}Hold'em · {{.Room.Name}}{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="space-y-6" data-holdem>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||||
|
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Back to the casino</span>
|
||||||
|
</a>
|
||||||
|
<h1 class="font-display text-3xl font-bold">Texas Hold'em</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--ink)]/50">Buy in, play as long as you like, leave with what's in front of you</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "_chipbar" .}}
|
||||||
|
|
||||||
|
<!-- The felt. Seats along the top, the board and the pot in the middle, you at
|
||||||
|
the bottom. Every seat has its own bet spot, and every chip on this table is
|
||||||
|
travelling between one of those and the pot — which is the whole difference
|
||||||
|
between poker and every other game in the room, where the chips only ever
|
||||||
|
move between you and the house. -->
|
||||||
|
<section class="pete-felt pete-poker relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div data-seats class="pete-poker-seats" aria-label="The other players"></div>
|
||||||
|
|
||||||
|
<div class="pete-poker-middle">
|
||||||
|
<div data-board class="pete-poker-board" aria-label="The board"></div>
|
||||||
|
|
||||||
|
<div class="pete-poker-pot">
|
||||||
|
<!-- The chips get a box of their own. .pete-stack is absolutely
|
||||||
|
positioned over whatever contains it, so a pile sharing a box with
|
||||||
|
the number under it paints straight over the number. -->
|
||||||
|
<div class="pete-poker-pot-pile">
|
||||||
|
<div class="pete-stack" data-pot-stack></div>
|
||||||
|
</div>
|
||||||
|
<span class="pete-poker-pot-label">Pot</span>
|
||||||
|
<span data-pot-total class="pete-poker-pot-total tabular-nums">0</span>
|
||||||
|
<span data-side class="pete-poker-side hidden"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-[2.75rem] items-center justify-center">
|
||||||
|
<p data-verdict class="pete-poker-verdict hidden" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pete-poker-you flex flex-col items-center" data-you></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The rail. This table has no corner free for the house's rack — the seats
|
||||||
|
run along the top and your hand is under the board — so it takes
|
||||||
|
solitaire's rail and sits off the felt entirely. -->
|
||||||
|
<aside class="pete-rail">
|
||||||
|
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
|
||||||
|
<span data-chip="500" style="--stack: 5"></span>
|
||||||
|
<span data-chip="100" style="--stack: 7"></span>
|
||||||
|
<span data-chip="25" style="--stack: 4"></span>
|
||||||
|
<span data-chip="5" style="--stack: 6"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="pete-meter" data-meter>
|
||||||
|
<span class="pete-meter-label">Blinds</span>
|
||||||
|
<span data-blinds class="pete-meter-value">—</span>
|
||||||
|
</div>
|
||||||
|
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Acting: shown when the hand is yours to play. -->
|
||||||
|
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" data-move="fold"
|
||||||
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
|
||||||
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Fold
|
||||||
|
</button>
|
||||||
|
<button type="button" data-move="check"
|
||||||
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
|
||||||
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
<button type="button" data-move="call"
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Call <span data-call-amount class="tabular-nums"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-raise-row class="mt-4 flex flex-wrap items-center gap-3 border-t-2 border-[color:var(--ink)]/5 pt-4">
|
||||||
|
<div class="pete-raise">
|
||||||
|
<input type="range" data-raise-slider aria-label="How much to raise to">
|
||||||
|
<span data-raise-to class="pete-raise-to">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<button type="button" data-raise-preset="0.5" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">½ pot</button>
|
||||||
|
<button type="button" data-raise-preset="1" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Pot</button>
|
||||||
|
<button type="button" data-raise-preset="max" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Max</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-move="raise"
|
||||||
|
class="ml-auto rounded-full bg-[color:var(--accent)] px-7 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
<span data-raise-verb>Raise to</span> <span data-raise-label class="tabular-nums"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Between hands: deal the next one, put more chips out, or get up. -->
|
||||||
|
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
|
||||||
|
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
|
||||||
|
Bought in for <span data-bought-in class="tabular-nums">0</span> · the house has taken <span data-session-rake class="tabular-nums">0</span> in rake
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" data-topup
|
||||||
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
|
||||||
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Top up
|
||||||
|
</button>
|
||||||
|
<button type="button" data-leave
|
||||||
|
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
|
||||||
|
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Get up
|
||||||
|
</button>
|
||||||
|
<button type="button" data-deal
|
||||||
|
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Next hand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sitting down: shown when you aren't at a table. -->
|
||||||
|
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">What are you playing for?</div>
|
||||||
|
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{{range .Stakes}}
|
||||||
|
<button type="button" data-tier="{{.Slug}}"
|
||||||
|
data-min="{{.MinBuy}}" data-max="{{.MaxBuy}}" data-bb="{{.BB}}"
|
||||||
|
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||||
|
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.SB}}/{{.BB}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||||
|
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||||
|
Buy in {{.MinBuy}}–{{.MaxBuy}}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How many of them?</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5" data-bots>
|
||||||
|
<button type="button" data-bot-count="1" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">1</button>
|
||||||
|
<button type="button" data-bot-count="2" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">2</button>
|
||||||
|
<button type="button" data-bot-count="3" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">3</button>
|
||||||
|
<button type="button" data-bot-count="4" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">4</button>
|
||||||
|
<button type="button" data-bot-count="5" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">5</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-bots-note></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
|
||||||
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
|
||||||
|
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" data-sit
|
||||||
|
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||||
|
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||||
|
Sit down
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||||
|
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
|
||||||
|
</p>
|
||||||
|
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script src="/static/js/casino-fx.js" defer></script>
|
||||||
|
<script src="/static/js/casino-cards.js" defer></script>
|
||||||
|
<script src="/static/js/games.js" defer></script>
|
||||||
|
<script src="/static/js/holdem.js" defer></script>
|
||||||
|
{{end}}
|
||||||
@@ -399,21 +399,133 @@ A multi-session build. This section is the handover; read it before anything els
|
|||||||
remembering as a rule: **size a card by its vars, never by the box you put it
|
remembering as a rule: **size a card by its vars, never by the box you put it
|
||||||
in.**
|
in.**
|
||||||
|
|
||||||
|
|
||||||
|
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
|
||||||
|
browser. The bots had to be retrained from scratch — see below, it is the whole
|
||||||
|
story of this phase.)*
|
||||||
|
- **You buy in, you play, you leave with what's in front of you.** This is the
|
||||||
|
only table in the casino that is a *session* rather than a game. Everywhere else
|
||||||
|
stakes once and pays a multiple; poker isn't that shape. So the live row lives
|
||||||
|
across hands, and chips cross the border exactly twice: once when you sit down
|
||||||
|
and once when you get up. In between every pot is inside the engine and storage
|
||||||
|
sees none of it. Three stakes (1/2, 5/10, 25/50), buy in for 20–100 big blinds,
|
||||||
|
top up between hands, bust and the session simply ends.
|
||||||
|
- **The rake is a real cardroom's rake**: five percent of a pot that *sees a flop*,
|
||||||
|
capped at three big blinds. No flop, no drop — so folding your blind round after
|
||||||
|
round costs you the blinds and no fee. Still winnings-only in the sense that
|
||||||
|
matters: you pay it out of a pot you win, never out of a bet you lose.
|
||||||
|
- **The bots move inside ApplyMove**, as UNO's do, which is what keeps poker off a
|
||||||
|
socket. One request plays your action, every bot action behind it, and whatever
|
||||||
|
streets that finishes — so shoving all-in returns the flop, turn, river, showdown
|
||||||
|
and payout in a single response, as a script the felt plays back.
|
||||||
|
- **The CFR policy was a lie, twice over, and this is the part worth reading.**
|
||||||
|
§5 called it "the single highest-value asset in either repository". It was not
|
||||||
|
being used *at all*, and could not have been:
|
||||||
|
1. **The key never matched.** The trainer packed a single "am I last to act" bit
|
||||||
|
and wrote its keys as `IP`/`OOP`. The runtime looked them up with the labels a
|
||||||
|
player would recognise — `BTN`, `SB`, `BB`, `UTG`. Not one key ever hit, for
|
||||||
|
the entire life of the game in gogobee. Nothing looked broken: a policy miss is
|
||||||
|
not an error, it is a silent fall back to a pot-odds heuristic. The bots played;
|
||||||
|
they just never once read the five million iterations sitting in policy.gob.
|
||||||
|
2. **And it was the wrong game anyway.** `TrainCFR` opens with `stack0, stack1 = 20,
|
||||||
|
20` at 1/2 blinds — a **ten big blind** push-fold stack. 82% of its nodes are in
|
||||||
|
the "stack smaller than the pot" bucket. A 20–100BB cash game lands almost
|
||||||
|
nowhere near it. Worse, the tree it trained on was not hold'em: a call always
|
||||||
|
ended the street (so no big-blind option and no check-check), turn order was
|
||||||
|
history-length parity, and the payoff was ±half the pot regardless of who had
|
||||||
|
put what in.
|
||||||
|
- **So the trainer was rewritten to play the real engine.** `internal/games/holdem/
|
||||||
|
train.go` + `cmd/holdem-train`. External-sampling MCCFR, every move applied through
|
||||||
|
`Step` — the same reducer the felt calls — so the blinds, the min-raise, street
|
||||||
|
completion, side pots and the money are the ones a player actually meets. The
|
||||||
|
stack depth is drawn fresh every hand across the whole 20–100BB range, because
|
||||||
|
poker at twenty big blinds and poker at a hundred are different games and a bot
|
||||||
|
that only knows one folds into the other.
|
||||||
|
- **The key is built by one function, `State.spot`, called by both the trainer and
|
||||||
|
the table.** That is the entire fix for bug (1), and it is structural: they cannot
|
||||||
|
drift apart because there is only one of them. And because a miss is *still* silent,
|
||||||
|
`Hits`/`Misses` are counted at the point a bot looks itself up, and
|
||||||
|
`TestTheBotsAreActuallyTrained` fails if the heads-up hit rate drops under 60%. It
|
||||||
|
is 95%. Multiway degrades on purpose — the policy is heads-up, six-handed reuses it
|
||||||
|
as a documented approximation, and the rest falls through to pot odds.
|
||||||
|
- **Three money bugs, and the tests earned their keep.** `TestChipsAreConserved`
|
||||||
|
plays a hundred sessions of real hands and counts every chip after every move; it
|
||||||
|
caught an **uncalled bet that minted chips** (the rule skipped folded players when
|
||||||
|
working out what had been matched, so a river bet folded to came back *whole*,
|
||||||
|
including the part called on the flop). A Go var-init ordering trap made `deck52`
|
||||||
|
build from an empty conversion table, so every card was identical, every showdown
|
||||||
|
tied and **every bot believed it held exactly 50% equity** — package-level vars are
|
||||||
|
built before `init()` runs. And the browser found the third: **the rake was
|
||||||
|
silently zero**, because the tiers declared `RakePct: 5` meaning percent while
|
||||||
|
`New()` overwrites it with blackjack's `0.05` meaning a fraction, and the integer
|
||||||
|
arithmetic floored 5% of a hundredth of the pot to nothing. Every rake test built
|
||||||
|
its own `State` by hand and so never saw the number the table runs on. There is now
|
||||||
|
one that does.
|
||||||
|
- **Driven in a browser, 2026-07-14, and it holds up.** Sitting down took 500 off a
|
||||||
|
5,000 stack and put it on the table; a hand played out; getting up put 1,004 back
|
||||||
|
(money conserved to the chip). The raise slider, the pot/half-pot/max presets, a
|
||||||
|
shove that runs the whole board out in one response, and a reload mid-hand that
|
||||||
|
brings back the hand, the board, the pot, the street and the stack — all clean.
|
||||||
|
**Side pots and split pots balance**: a three-way all-in paid 758 + 757 + 920 out
|
||||||
|
of a 2,495 pot with 60 raked, and `paid + rake == pot` on every multi-winner hand
|
||||||
|
sampled. A bot's cards never cross the wire until a showdown, and a folded seat's
|
||||||
|
never do at all. Six-handed at 390px: no sideways overflow, nothing colliding.
|
||||||
|
Console silent.
|
||||||
|
- Two felt bugs only the browser could show. **`.pete-stack` is `position: absolute;
|
||||||
|
inset: 0`** — so the pot's chips, sharing a box with the number under them, painted
|
||||||
|
straight over it: the pot showed a chip and no total. The pile needs a box of its
|
||||||
|
own. And **a `.pete-spot` is 7rem across** because blackjack has exactly one of
|
||||||
|
them; six of those is most of a felt, so a seat's bet spot is less than half the
|
||||||
|
size, with chips scaled to match. The bet total hangs *below* the ring
|
||||||
|
(`.pete-spot-total`), which is the existing rule for exactly this reason.
|
||||||
|
|
||||||
|
### Pick this up here — a 20M-hand policy is still training
|
||||||
|
|
||||||
|
The `policy.gob` on main is a **300,000-hand run** — a placeholder. It works (95%
|
||||||
|
heads-up hit rate, and the bots play a real game off it), but it is thin: 4,129
|
||||||
|
nodes. A **20,000,000-hand run** was started on millenia on 2026-07-14 and needs
|
||||||
|
collecting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh reala@192.168.1.212 'tail -2 ~/pete-train/train.log' # is it done?
|
||||||
|
scp reala@192.168.1.212:~/pete-train/policy-new.gob internal/games/holdem/policy.gob
|
||||||
|
go test ./internal/games/holdem/ -run TestTheBotsAreActuallyTrained -v # hit rate must hold ≥60%
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-drive the table in a browser and commit it. If the run was lost, just do it
|
||||||
|
again — it is one command and about an hour:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rsync -az --delete --exclude .git --exclude node_modules ./ reala@192.168.1.212:~/pete-train/
|
||||||
|
ssh reala@192.168.1.212 'cd ~/pete-train && go build ./cmd/holdem-train && \
|
||||||
|
nohup ./holdem-train -iterations 20000000 -workers 30 -out ~/pete-train/policy-new.gob \
|
||||||
|
> ~/pete-train/train.log 2>&1 &'
|
||||||
|
```
|
||||||
|
|
||||||
|
millenia (`reala@192.168.1.212`) has 32 cores and does ~250k hands a minute. The
|
||||||
|
local box does ~110k. Nothing about the *code* is waiting on this — the policy is a
|
||||||
|
data file, and a better one only makes the bots harder.
|
||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. **Phase 4 — hold'em**, as below. It is the last game on the list, and the first
|
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
|
||||||
one where a hand has other people in it: the spot is a singleton and the chip
|
five are sitting on main un-deployed — the live casino is blackjack and nothing
|
||||||
animations are tuned for one seat (see "still open" below).
|
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
|
||||||
2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are
|
binary is out there, but the first player to try a ladder in the first minute
|
||||||
still sitting on main un-deployed — the live casino is blackjack and nothing else.
|
after a deploy gets the 503.
|
||||||
The server runs `StartTriviaBank`, so trivia's bank fills itself once the binary
|
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
|
||||||
is out there, but the first player to try a ladder in the first minute after a
|
no-mercy)" and only normal was ever built. gogobee has the rules
|
||||||
deploy gets the 503.
|
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
|
||||||
|
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
|
||||||
|
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
|
||||||
|
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
|
||||||
|
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
|
||||||
|
completely. Shipping No Mercy on the regular tier's prices would misprice it.
|
||||||
|
|
||||||
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
|
||||||
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
|
||||||
it, and the chip animations are tuned for one player — a second seat would need the
|
at six seats, and the rest is pot odds) — a multiway policy would want its own
|
||||||
spot to be per-seat rather than the singleton it is now.
|
training run with more than two seats in the tree. Blackjack still has no **split**.
|
||||||
|
|
||||||
### How the browser half fits together
|
### How the browser half fits together
|
||||||
|
|
||||||
@@ -592,20 +704,31 @@ into `pete/internal/games/`, let them drift, no shared module.
|
|||||||
|
|
||||||
| Game | Copy | Rewrite | Notes |
|
| Game | Copy | Rewrite | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Hold'em** | ~2,700 LOC | the shell | The crown jewel. Take it all. |
|
| **Hold'em** | ~1,400 LOC | the shell + **the whole CFR trainer** | See the warning below. |
|
||||||
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
|
| **UNO** | ~1,400 LOC | the turn engine | Great primitives, unshippable engine. |
|
||||||
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
|
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
|
||||||
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
|
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
|
||||||
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
|
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
|
||||||
|
|
||||||
### Hold'em — take almost all of it
|
### Hold'em — take the poker, not the brain
|
||||||
|
|
||||||
|
> **This section was wrong, and it cost most of Phase 4. Read §0's hold'em entry before
|
||||||
|
> you believe any of it.** `data/policy.gob` is **not** "the single highest-value asset in
|
||||||
|
> either repo". It is a 10-big-blind push-fold policy, trained against a model of poker
|
||||||
|
> that is not poker (a call always ends the street), and *it was never once read* — the
|
||||||
|
> trainer wrote its keys under `IP`/`OOP` and the runtime looked them up under
|
||||||
|
> `BTN`/`SB`/`BB`, so every lookup in the history of the game missed and fell through to a
|
||||||
|
> pot-odds heuristic. Nothing about that is visible from the outside: a policy miss is not
|
||||||
|
> an error. **Retrain against your own engine.** Pete now does — `internal/games/holdem/
|
||||||
|
> train.go` plays every move through the real reducer, and both the trainer and the table
|
||||||
|
> build the info-set key with the same function so they cannot drift apart again.
|
||||||
|
|
||||||
Already mautrix-free, verified by import check:
|
Already mautrix-free, verified by import check:
|
||||||
|
|
||||||
- `holdem_cfr.go` (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a
|
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
|
||||||
`uint64`, regret pruning, board-texture/SPR/equity bucketing. Plus the trained
|
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
|
||||||
`data/policy.gob` (3.4 MB) and `cmd/holdem-train`, `cmd/holdem-seed`. **This is the single
|
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
|
||||||
highest-value asset in either repo.**
|
not: see the warning above.
|
||||||
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
|
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
|
||||||
draw/out detection. 100% pure, well tested.
|
draw/out detection. 100% pure, well tested.
|
||||||
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
|
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
|
||||||
|
|||||||
Reference in New Issue
Block a user