Compare commits
33 Commits
weather-fx
...
638e28263a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
638e28263a | ||
|
|
b96879d25c | ||
|
|
903c5accdb | ||
|
|
e6c1bd3b54 | ||
|
|
6e20883e5d | ||
|
|
d7e63d86a6 | ||
|
|
79c857023f | ||
|
|
3e9b93af55 | ||
|
|
2d653bf439 | ||
|
|
c62d736223 | ||
|
|
feb353f789 | ||
|
|
5ca056bf20 | ||
|
|
fe2195e85f | ||
|
|
d29a311eff | ||
|
|
6961f90634 | ||
|
|
b00da21a47 | ||
|
|
8ec13eab5b | ||
|
|
c69fbb63db | ||
|
|
cb84e1d549 | ||
|
|
44613c4760 | ||
|
|
a442cfccaa | ||
|
|
f9a98f72a6 | ||
|
|
8310b30439 | ||
|
|
6ccd18452c | ||
|
|
e85ebe56f7 | ||
|
|
99574db3e9 | ||
|
|
8cb5b38599 | ||
|
|
a614077cff | ||
|
|
82d1c6ebeb | ||
|
|
10bcc78c51 | ||
|
|
8e0d6aff3e | ||
|
|
9bf56cbb4e | ||
|
|
4c671fb410 |
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)
|
||||
}
|
||||
@@ -58,6 +58,12 @@ client_secret = "${PETE_OIDC_CLIENT_SECRET}"
|
||||
redirect_url = "https://news.parodia.dev/auth/callback"
|
||||
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||
session_secret = "${PETE_SESSION_SECRET}"
|
||||
# Share the session across sibling hosts, so signing in on news.parodia.dev also
|
||||
# signs you in on games.parodia.dev. This widens the cookie to every host under
|
||||
# the domain, so leave it empty to keep the session host-only. Each host that
|
||||
# starts a login also needs its own redirect URI registered in Authentik
|
||||
# (<host>/auth/callback) — the login round-trip returns to the host it began on.
|
||||
cookie_domain = ""
|
||||
|
||||
# Optional Web Push digests. When enabled, signed-in users can opt in (from the
|
||||
# feed-settings panel) to a periodic "N new stories" notification, delivered via
|
||||
@@ -96,6 +102,16 @@ label = "Amy (US, female)"
|
||||
id = "en_US-ryan-high"
|
||||
label = "Ryan (US, male, HQ)"
|
||||
|
||||
# The casino (games.parodia.dev). Signed-in only — there is real money in it — so
|
||||
# it needs [web.auth] above, and web.auth.cookie_domain if the games host is a
|
||||
# different subdomain from the news one. Chips are 1:1 with gogobee euros and
|
||||
# cross the border through the escrow endpoints, which gogobee polls; matrix_server
|
||||
# is how a player is named to that ledger (@<authentik username>:<matrix_server>).
|
||||
[web.games]
|
||||
enabled = false
|
||||
host = "games.parodia.dev"
|
||||
matrix_server = "parodia.dev"
|
||||
|
||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // 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/go-jose/go-jose/v4 v4.1.4 // 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/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
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/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
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/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
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/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.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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
|
||||
@@ -17,9 +17,40 @@ type Config struct {
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Adventure AdventureConfig `toml:"adventure"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
|
||||
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
|
||||
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
|
||||
// default — the endpoint 404s and no adventure channel appears until enabled.
|
||||
type AdventureConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// IngestToken is the shared bearer secret gogobee presents on
|
||||
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
|
||||
IngestToken string `toml:"ingest_token"`
|
||||
// Channel is the Matrix channel name (a key in [matrix.channels]) that
|
||||
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
|
||||
// channel, adventure runs website-only (stories still appear on /adventure).
|
||||
Channel string `toml:"channel"`
|
||||
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
|
||||
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
|
||||
// "unset" and doesn't get silently rewritten to the default.
|
||||
DigestHour *int `toml:"digest_hour"`
|
||||
}
|
||||
|
||||
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
|
||||
// unset case. Safe on a zero-value AdventureConfig.
|
||||
func (a AdventureConfig) DigestHourOrDefault() int {
|
||||
if a.DigestHour == nil {
|
||||
return defaultDigestHour
|
||||
}
|
||||
return *a.DigestHour
|
||||
}
|
||||
|
||||
const defaultDigestHour = 17
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
@@ -29,6 +60,7 @@ type WebConfig struct {
|
||||
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
||||
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
||||
Games GamesConfig `toml:"games"` // optional casino (games.parodia.dev)
|
||||
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
||||
// owner-facing source-health dashboard at /status. Empty means the page is
|
||||
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
||||
@@ -77,6 +109,23 @@ type VoiceConfig struct {
|
||||
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
||||
}
|
||||
|
||||
// GamesConfig wires the casino. It is signed-in only — there is money in it —
|
||||
// so it does nothing without web.auth, and it needs the Matrix server name
|
||||
// because that is how a player's identity reaches gogobee's euro ledger: an
|
||||
// Authentik username is the Matrix localpart, and the Matrix id is the account.
|
||||
type GamesConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Host is the public hostname the casino answers on, e.g.
|
||||
// "games.parodia.dev". Requests arriving on it are served the casino at "/";
|
||||
// everywhere else the same pages live under /games. Empty means no host
|
||||
// branching, which is the normal state of affairs in local development.
|
||||
Host string `toml:"host"`
|
||||
// MatrixServer is the server name half of a player's Matrix id
|
||||
// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
|
||||
// identified in the economy and the casino stays shut.
|
||||
MatrixServer string `toml:"matrix_server"`
|
||||
}
|
||||
|
||||
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||
@@ -87,6 +136,12 @@ type AuthConfig struct {
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
||||
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
||||
// CookieDomain widens the session cookie beyond the host that set it, so a
|
||||
// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
|
||||
// to ".parodia.dev" to share the session across every parodia.dev host —
|
||||
// which is every host, including the landing site, so it is opt-in rather
|
||||
// than the default. Empty keeps the cookie host-only.
|
||||
CookieDomain string `toml:"cookie_domain"`
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
@@ -228,6 +283,21 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Adventure.Enabled {
|
||||
if c.Adventure.IngestToken == "" {
|
||||
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
|
||||
}
|
||||
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
|
||||
return fmt.Errorf("adventure.digest_hour must be 0-23")
|
||||
}
|
||||
if c.Adventure.Channel != "" {
|
||||
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
|
||||
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
|
||||
"channel", c.Adventure.Channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("sources[%d].name is required", i)
|
||||
|
||||
346
internal/games/blackjack/blackjack.go
Normal file
346
internal/games/blackjack/blackjack.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Package blackjack is a pure blackjack engine.
|
||||
//
|
||||
// It knows nothing about HTTP, sockets, timers, euros or players' names. You
|
||||
// hand it a state and a move, it hands you back a new state and the list of
|
||||
// things that just happened. Everything else — who is sitting there, what their
|
||||
// chips are, when their clock runs out — belongs to the shell in internal/games/table.
|
||||
//
|
||||
// That seam is the one thing gogobee's blackjack never had: there, the engine
|
||||
// *was* the message sender, so an "error" meant a Matrix send had failed rather
|
||||
// than that a player had tried something illegal. Here an error means exactly
|
||||
// one thing: the move was not legal in this state.
|
||||
//
|
||||
// The state is a plain value. It serializes, so a hand survives a redeploy, and
|
||||
// it replays, so a disputed hand can be dealt again from its seed.
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce. Callers can match on these to tell a
|
||||
// player "not now" rather than "something broke".
|
||||
var (
|
||||
ErrHandOver = errors.New("blackjack: the hand is already over")
|
||||
ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act")
|
||||
ErrUnknownMove = errors.New("blackjack: unknown move")
|
||||
ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards")
|
||||
ErrDeckExhausted = errors.New("blackjack: the shoe is empty")
|
||||
ErrBadBet = errors.New("blackjack: bet must be positive")
|
||||
)
|
||||
|
||||
// Phase is whose turn it is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlayer Phase = "player" // the player is acting
|
||||
PhaseDealer Phase = "dealer" // transient: the dealer is drawing out
|
||||
PhaseDone Phase = "done" // settled, Outcome and Payout are final
|
||||
)
|
||||
|
||||
// Outcome is how a finished hand finished, from the player's point of view.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2
|
||||
OutcomeWin Outcome = "win"
|
||||
OutcomeLose Outcome = "lose"
|
||||
OutcomePush Outcome = "push" // tie, stake returned
|
||||
OutcomeBust Outcome = "bust" // player went over 21
|
||||
OutcomeDealerBust Outcome = "dealer_bust"
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays the player more than their stake back.
|
||||
func (o Outcome) Won() bool {
|
||||
return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust
|
||||
}
|
||||
|
||||
// Rules are the table's terms. They're part of the state rather than a global,
|
||||
// so a hand always settles under the rules it was dealt under — even if the
|
||||
// house changes them mid-session.
|
||||
type Rules struct {
|
||||
Decks int `json:"decks"` // shoe size
|
||||
BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2
|
||||
DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does
|
||||
RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only
|
||||
}
|
||||
|
||||
// DefaultRules match the blackjack gogobee has been dealing in Matrix for years:
|
||||
// six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term
|
||||
// — see Settle for exactly what it touches.
|
||||
func DefaultRules() Rules {
|
||||
return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05}
|
||||
}
|
||||
|
||||
// State is one hand of heads-up blackjack: one player, one dealer. Splitting
|
||||
// isn't in v1, so there's exactly one player hand.
|
||||
type State struct {
|
||||
Rules Rules `json:"rules"`
|
||||
Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser
|
||||
Player []cards.Card `json:"player"`
|
||||
Dealer []cards.Card `json:"dealer"`
|
||||
|
||||
Bet int64 `json:"bet"` // chips at risk; doubles on a double-down
|
||||
Doubled bool `json:"doubled"`
|
||||
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
|
||||
// Payout is what returns to the player's chip stack when the hand is done:
|
||||
// stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut,
|
||||
// recorded so the ledger can account for every chip that left the table.
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table can narrate or animate. The engine emits them
|
||||
// instead of drawing anything itself.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle"
|
||||
Card *cards.Card `json:"card,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
type Move string
|
||||
|
||||
const (
|
||||
Hit Move = "hit"
|
||||
Stand Move = "stand"
|
||||
Double Move = "double"
|
||||
)
|
||||
|
||||
// HandValue totals a hand, counting each ace as 11 until that would bust, then
|
||||
// demoting them one at a time. soft reports whether an ace is still counting as
|
||||
// 11 — which is what makes "soft 17" a different thing from 17.
|
||||
func HandValue(hand []cards.Card) (total int, soft bool) {
|
||||
aces := 0
|
||||
for _, c := range hand {
|
||||
switch {
|
||||
case c.Rank == cards.Ace:
|
||||
aces++
|
||||
total += 11
|
||||
case c.Rank >= 10:
|
||||
total += 10
|
||||
default:
|
||||
total += int(c.Rank)
|
||||
}
|
||||
}
|
||||
for total > 21 && aces > 0 {
|
||||
total -= 10 // demote an ace from 11 to 1
|
||||
aces--
|
||||
}
|
||||
return total, aces > 0
|
||||
}
|
||||
|
||||
// IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled
|
||||
// from three cards is not one, and does not get paid 3:2.
|
||||
func IsBlackjack(hand []cards.Card) bool {
|
||||
if len(hand) != 2 {
|
||||
return false
|
||||
}
|
||||
v, _ := HandValue(hand)
|
||||
return v == 21
|
||||
}
|
||||
|
||||
// New deals a fresh hand: two to the player, two to the dealer. If either side
|
||||
// has a natural the hand is already over and the returned State is settled — a
|
||||
// player with blackjack never gets asked whether they'd like to hit.
|
||||
func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if r.Decks < 1 {
|
||||
r.Decks = 1
|
||||
}
|
||||
deck := cards.NewDeck(r.Decks)
|
||||
deck.Shuffle(rng)
|
||||
|
||||
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer}
|
||||
evs := []Event{{Kind: "deal"}}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// A natural on either side ends it before the player ever acts.
|
||||
if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) {
|
||||
s.settle(&evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// draw takes one card off the shoe onto the given hand and records the event.
|
||||
// Pointer receiver: it mutates the deck and the hand together, and neither may
|
||||
// end up applied to a stale copy of the state.
|
||||
func (s *State) draw(hand *[]cards.Card, kind string, evs *[]Event) error {
|
||||
c, ok := s.Deck.Draw()
|
||||
if !ok {
|
||||
return ErrDeckExhausted
|
||||
}
|
||||
*hand = append(*hand, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: kind, Card: &card})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMove is the whole engine: a legal move in, a new state and the events it
|
||||
// produced out. An error means the move was illegal and the state is unchanged.
|
||||
//
|
||||
// s is taken by value, so the caller's state is only replaced on success.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrHandOver
|
||||
}
|
||||
// A copied State still shares its slices' backing arrays with the original.
|
||||
// Two moves applied from the same starting state would then append cards over
|
||||
// each other. Clone first: the caller's state is genuinely untouched, and a
|
||||
// state can be replayed as many times as we like.
|
||||
s = s.clone()
|
||||
if s.Phase != PhasePlayer {
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
if m == Double && len(s.Player) != 2 {
|
||||
// Doubling means doubling the stake for exactly one more card. Only ever
|
||||
// legal on the opening two — after that you're just describing a hit.
|
||||
return s, nil, ErrCantDouble
|
||||
}
|
||||
if m != Hit && m != Stand && m != Double {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
evs := []Event{}
|
||||
|
||||
if m == Double {
|
||||
s.Bet *= 2
|
||||
s.Doubled = true
|
||||
}
|
||||
|
||||
if m == Hit || m == Double {
|
||||
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
if v, _ := HandValue(s.Player); v > 21 {
|
||||
s.settle(&evs) // bust; the dealer never has to play
|
||||
return s, evs, nil
|
||||
}
|
||||
if m == Hit {
|
||||
return s, evs, nil // still the player's turn
|
||||
}
|
||||
}
|
||||
|
||||
// Stand, or a double that survived its card: the dealer draws out.
|
||||
s.Phase = PhaseDealer
|
||||
s.dealerPlay(&evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// dealerPlay draws the dealer out to the house rule, then settles. The dealer
|
||||
// has no choices to make — that's the game — so this needs no move.
|
||||
func (s *State) dealerPlay(evs *[]Event) {
|
||||
*evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over
|
||||
for {
|
||||
v, soft := HandValue(s.Dealer)
|
||||
hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft
|
||||
if v >= 17 && !hitSoft17 {
|
||||
break
|
||||
}
|
||||
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil {
|
||||
break // shoe ran dry mid-draw; settle on what's on the table
|
||||
}
|
||||
}
|
||||
s.settle(evs)
|
||||
}
|
||||
|
||||
// settle decides the outcome and the payout, and is the only place chips are
|
||||
// computed.
|
||||
//
|
||||
// The rake comes off winnings, never off the stake: a player who pushes gets
|
||||
// exactly their bet back, and a player who loses is never charged for the
|
||||
// privilege. The house only takes a cut of money the house was going to hand
|
||||
// over anyway. That's a rake, as opposed to a fee for showing up.
|
||||
func (s *State) settle(evs *[]Event) {
|
||||
playerVal, _ := HandValue(s.Player)
|
||||
dealerVal, _ := HandValue(s.Dealer)
|
||||
playerBJ := IsBlackjack(s.Player)
|
||||
dealerBJ := IsBlackjack(s.Dealer)
|
||||
|
||||
// profit is what the player wins on top of their stake. Negative means the
|
||||
// stake is gone.
|
||||
var profit int64
|
||||
|
||||
switch {
|
||||
case playerVal > 21:
|
||||
s.Outcome = OutcomeBust
|
||||
profit = -s.Bet
|
||||
case playerBJ && dealerBJ:
|
||||
s.Outcome = OutcomePush
|
||||
case playerBJ:
|
||||
s.Outcome = OutcomeBlackjack
|
||||
profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays))
|
||||
case dealerBJ:
|
||||
s.Outcome = OutcomeLose
|
||||
profit = -s.Bet
|
||||
case dealerVal > 21:
|
||||
s.Outcome = OutcomeDealerBust
|
||||
profit = s.Bet
|
||||
case playerVal > dealerVal:
|
||||
s.Outcome = OutcomeWin
|
||||
profit = s.Bet
|
||||
case playerVal == dealerVal:
|
||||
s.Outcome = OutcomePush
|
||||
default:
|
||||
s.Outcome = OutcomeLose
|
||||
profit = -s.Bet
|
||||
}
|
||||
|
||||
if profit > 0 {
|
||||
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||
if s.Rake < 0 {
|
||||
s.Rake = 0
|
||||
}
|
||||
profit -= s.Rake
|
||||
}
|
||||
if profit < 0 {
|
||||
s.Payout = 0 // stake is lost; nothing comes back
|
||||
} else {
|
||||
s.Payout = s.Bet + profit
|
||||
}
|
||||
|
||||
s.Phase = PhaseDone
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)})
|
||||
}
|
||||
|
||||
// Net is what the hand did to the player's chip stack: payout minus the stake
|
||||
// they put up. Negative on a loss, zero on a push.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// CanDouble reports whether Double is legal right now — the shell asks this to
|
||||
// decide whether to light the button up.
|
||||
func (s State) CanDouble() bool {
|
||||
return s.Phase == PhasePlayer && len(s.Player) == 2
|
||||
}
|
||||
|
||||
// clone deep-copies the slices so a derived state shares no backing array with
|
||||
// the one it came from.
|
||||
func (s State) clone() State {
|
||||
s.Deck = append(cards.Deck(nil), s.Deck...)
|
||||
s.Player = append([]cards.Card(nil), s.Player...)
|
||||
s.Dealer = append([]cards.Card(nil), s.Dealer...)
|
||||
return s
|
||||
}
|
||||
415
internal/games/blackjack/blackjack_test.go
Normal file
415
internal/games/blackjack/blackjack_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// hand builds a hand from "A♠"-ish shorthand: rank letters/numbers only.
|
||||
func hand(ranks ...cards.Rank) []cards.Card {
|
||||
h := make([]cards.Card, len(ranks))
|
||||
for i, r := range ranks {
|
||||
h[i] = cards.Card{Rank: r, Suit: cards.Spades}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHandValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hand []cards.Card
|
||||
want int
|
||||
soft bool
|
||||
}{
|
||||
{"two aces are 12, not 22", hand(cards.Ace, cards.Ace), 12, true},
|
||||
{"ace plus king is a soft 21", hand(cards.Ace, cards.King), 21, true},
|
||||
{"faces are all ten", hand(cards.Jack, cards.Queen), 20, false},
|
||||
{"ace demotes to save the hand", hand(cards.Ace, 9, 5), 15, false},
|
||||
{"three aces and an eight", hand(cards.Ace, cards.Ace, cards.Ace, 8), 21, true},
|
||||
{"soft 17 is an ace and a six", hand(cards.Ace, 6), 17, true},
|
||||
{"hard 17 has no ace", hand(cards.King, 7), 17, false},
|
||||
{"a bust stays busted", hand(cards.King, cards.Queen, 5), 25, false},
|
||||
{"empty hand", nil, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, soft := HandValue(tc.hand)
|
||||
if got != tc.want || soft != tc.soft {
|
||||
t.Fatalf("HandValue = (%d, soft=%v), want (%d, soft=%v)", got, soft, tc.want, tc.soft)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
|
||||
if !IsBlackjack(hand(cards.Ace, cards.King)) {
|
||||
t.Fatal("A+K is a natural")
|
||||
}
|
||||
// 21 built from three cards is not a natural and must not be paid 3:2.
|
||||
if IsBlackjack(hand(7, 7, 7)) {
|
||||
t.Fatal("7+7+7 is 21 but not a blackjack")
|
||||
}
|
||||
if IsBlackjack(hand(cards.Ace)) {
|
||||
t.Fatal("one card is not a blackjack")
|
||||
}
|
||||
}
|
||||
|
||||
// settleWith forces a finished hand and reads back the money, bypassing the
|
||||
// deal so the payout math can be checked case by case.
|
||||
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
|
||||
t.Helper()
|
||||
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer}
|
||||
evs := []Event{}
|
||||
s.settle(&evs)
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("settle left the hand unfinished")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSettle_PayoutsAndRake(t *testing.T) {
|
||||
r := DefaultRules() // 3:2, 5% rake
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
player []cards.Card
|
||||
dealer []cards.Card
|
||||
wantOutcome Outcome
|
||||
wantPayout int64 // chips returned to the stack
|
||||
wantRake int64
|
||||
}{
|
||||
{
|
||||
// 100 stake, 100 profit, 5 raked → 195 back, net +95.
|
||||
name: "a plain win is raked on the profit only",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeWin, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
{
|
||||
// 3:2 on 100 is 150 profit, 7 raked (floor of 7.5) → 243 back.
|
||||
name: "a natural pays 3:2 less rake",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.King, 8),
|
||||
wantOutcome: OutcomeBlackjack, wantPayout: 243, wantRake: 7,
|
||||
},
|
||||
{
|
||||
name: "a push returns the stake untouched — the house takes nothing",
|
||||
player: hand(cards.King, 9), dealer: hand(cards.Queen, 9),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "two naturals push",
|
||||
player: hand(cards.Ace, cards.King), dealer: hand(cards.Ace, cards.Queen),
|
||||
wantOutcome: OutcomePush, wantPayout: 100, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a loss pays nothing and is not charged a rake",
|
||||
player: hand(cards.King, 8), dealer: hand(cards.King, 9),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "a bust pays nothing even if the dealer would have busted too",
|
||||
player: hand(cards.King, 8, 9), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeBust, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer blackjack beats the player's twenty",
|
||||
player: hand(cards.King, cards.Queen), dealer: hand(cards.Ace, cards.Jack),
|
||||
wantOutcome: OutcomeLose, wantPayout: 0, wantRake: 0,
|
||||
},
|
||||
{
|
||||
name: "dealer bust pays even money less rake",
|
||||
player: hand(cards.King, 5), dealer: hand(cards.King, 6, 9),
|
||||
wantOutcome: OutcomeDealerBust, wantPayout: 195, wantRake: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := settleWith(t, r, 100, tc.player, tc.dealer)
|
||||
if s.Outcome != tc.wantOutcome {
|
||||
t.Errorf("outcome = %q, want %q", s.Outcome, tc.wantOutcome)
|
||||
}
|
||||
if s.Payout != tc.wantPayout {
|
||||
t.Errorf("payout = %d, want %d", s.Payout, tc.wantPayout)
|
||||
}
|
||||
if s.Rake != tc.wantRake {
|
||||
t.Errorf("rake = %d, want %d", s.Rake, tc.wantRake)
|
||||
}
|
||||
// The invariant the ledger depends on: every chip the player staked
|
||||
// either comes back, goes to the house as rake, or is lost to the table.
|
||||
if s.Payout < 0 || s.Rake < 0 {
|
||||
t.Errorf("negative chips: payout=%d rake=%d", s.Payout, s.Rake)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettle_RakeNeverTouchesTheStake(t *testing.T) {
|
||||
// A 100% rake is absurd, but it must still never claw back a player's own
|
||||
// stake: the worst a rake can do is take all the winnings.
|
||||
r := Rules{Decks: 6, BlackjackPays: 1.5, RakePct: 1.0}
|
||||
s := settleWith(t, r, 100, hand(cards.King, 9), hand(cards.King, 8))
|
||||
if s.Payout != 100 {
|
||||
t.Fatalf("payout = %d, want the stake back (100)", s.Payout)
|
||||
}
|
||||
if s.Net() != 0 {
|
||||
t.Fatalf("net = %d, want 0", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||
rng := cards.NewRNG(1, 2)
|
||||
s, evs, err := New(50, DefaultRules(), rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Player) != 2 || len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
|
||||
}
|
||||
if len(s.Deck) != 6*52-4 {
|
||||
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "deal" {
|
||||
t.Fatal("no deal event")
|
||||
}
|
||||
// Unless somebody was dealt a natural, it's the player's move.
|
||||
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsNonPositiveBet(t *testing.T) {
|
||||
for _, bet := range []int64{0, -100} {
|
||||
if _, _, err := New(bet, DefaultRules(), cards.NewRNG(1, 2)); err == nil {
|
||||
t.Fatalf("bet %d was accepted", bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NaturalSettlesImmediately(t *testing.T) {
|
||||
// Search seeds for a deal that gives the player a natural, then assert the
|
||||
// hand is already over — a player holding blackjack is never asked to hit.
|
||||
for seed := uint64(1); seed < 200; seed++ {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(seed, seed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsBlackjack(s.Player) {
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Hit); err != ErrHandOver {
|
||||
t.Fatalf("seed %d: hitting a settled natural gave %v, want ErrHandOver", seed, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Skip("no natural dealt in 200 seeds")
|
||||
}
|
||||
|
||||
func TestApplyMove_HitUntilBustSettles(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(7, 7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural; not the hand under test")
|
||||
}
|
||||
for i := 0; i < 12 && s.Phase == PhasePlayer; i++ {
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("hitting a dozen times never ended the hand")
|
||||
}
|
||||
if v, _ := HandValue(s.Player); v <= 21 {
|
||||
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
|
||||
}
|
||||
if s.Outcome != OutcomeBust || s.Payout != 0 {
|
||||
t.Fatalf("outcome=%q payout=%d, want bust/0", s.Outcome, s.Payout)
|
||||
}
|
||||
// A busted player must not have made the dealer draw.
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealer drew %d cards against a busted player", len(s.Dealer)-2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_StandRunsTheDealerOut(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(3, 9))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, evs, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after stand, want done", s.Phase)
|
||||
}
|
||||
v, soft := HandValue(s.Dealer)
|
||||
if v < 17 {
|
||||
t.Fatalf("dealer stood on %d, must draw below 17", v)
|
||||
}
|
||||
if v == 17 && soft {
|
||||
t.Fatal("dealer stood on soft 17; the house rule says hit")
|
||||
}
|
||||
var reveal bool
|
||||
for _, e := range evs {
|
||||
if e.Kind == "reveal" {
|
||||
reveal = true
|
||||
}
|
||||
}
|
||||
if !reveal {
|
||||
t.Fatal("dealer played without a reveal event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(11, 4))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
if !s.CanDouble() {
|
||||
t.Fatal("double should be legal on the opening two cards")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Doubled || s.Bet != 200 {
|
||||
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
|
||||
}
|
||||
if len(s.Player) != 3 {
|
||||
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("a double must end the player's turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_DoubleIsIllegalAfterHitting(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(5, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
s, _, err = ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhasePlayer {
|
||||
t.Skip("busted on the hit; not the hand under test")
|
||||
}
|
||||
before := s.Bet
|
||||
after, _, err := ApplyMove(s, Double)
|
||||
if err != ErrCantDouble {
|
||||
t.Fatalf("double after a hit gave %v, want ErrCantDouble", err)
|
||||
}
|
||||
if after.Bet != before {
|
||||
t.Fatalf("a rejected double still moved the bet: %d -> %d", before, after.Bet)
|
||||
}
|
||||
if s.CanDouble() {
|
||||
t.Fatal("CanDouble says yes on a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMove_RejectsGarbage(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(2, 8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move("surrender")); err != ErrUnknownMove {
|
||||
t.Fatalf("got %v, want ErrUnknownMove", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's state has to survive a redeploy: no timers, no pointers, no
|
||||
// unexported fields that JSON would quietly drop.
|
||||
func TestState_RoundTripsThroughJSON(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(13, 21))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Play both forward identically; a state that survives the trip settles the same.
|
||||
live, _, err := ApplyMove(s, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revived, _, err := ApplyMove(back, Stand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if live.Outcome != revived.Outcome || live.Payout != revived.Payout {
|
||||
t.Fatalf("revived hand settled differently: %q/%d vs %q/%d",
|
||||
revived.Outcome, revived.Payout, live.Outcome, live.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same seed, same shoe — this is what lets a disputed hand be re-dealt.
|
||||
func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
|
||||
a, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, DefaultRules(), cards.NewRNG(42, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
|
||||
cards.Hand(a.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), cards.Hand(b.Dealer))
|
||||
}
|
||||
}
|
||||
|
||||
// A State handed to ApplyMove twice must produce two independent hands. If the
|
||||
// engine let derived states share a backing array, the second deal would scribble
|
||||
// over the first one's cards — and a player could watch a card change under them.
|
||||
func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
|
||||
s, _, err := New(100, DefaultRules(), cards.NewRNG(23, 5))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
before := cards.Hand(s.Player)
|
||||
|
||||
a, _, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aHand := cards.Hand(a.Player)
|
||||
|
||||
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cards.Hand(a.Player); got != aHand {
|
||||
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
||||
}
|
||||
if got := cards.Hand(s.Player); got != before {
|
||||
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
||||
}
|
||||
}
|
||||
126
internal/games/cards/cards.go
Normal file
126
internal/games/cards/cards.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Package cards holds the deck primitives every card game on Pete shares.
|
||||
//
|
||||
// gogobee never had this: blackjack carried its own deck, UNO carried another,
|
||||
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
|
||||
// three times. The games ported over here consolidate onto this instead.
|
||||
//
|
||||
// Two rules hold throughout:
|
||||
//
|
||||
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
|
||||
// so a hand is reproducible from its seed — which is what makes the engines
|
||||
// testable, and what lets us re-deal a disputed hand and show the player exactly
|
||||
// what the shoe did.
|
||||
//
|
||||
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
|
||||
// game in progress serializes to JSON and survives a redeploy.
|
||||
package cards
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Suit is one of the four French suits.
|
||||
type Suit uint8
|
||||
|
||||
const (
|
||||
Spades Suit = iota
|
||||
Hearts
|
||||
Diamonds
|
||||
Clubs
|
||||
)
|
||||
|
||||
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
|
||||
// (blackjack's soft 11, hold'em's wheel) say so themselves.
|
||||
type Rank uint8
|
||||
|
||||
const (
|
||||
Ace Rank = 1
|
||||
Jack Rank = 11
|
||||
Queen Rank = 12
|
||||
King Rank = 13
|
||||
)
|
||||
|
||||
var (
|
||||
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
|
||||
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||
)
|
||||
|
||||
// Card is one playing card. The short JSON keys keep a serialized shoe small —
|
||||
// a six-deck blackjack state is 312 of these.
|
||||
type Card struct {
|
||||
Rank Rank `json:"r"`
|
||||
Suit Suit `json:"s"`
|
||||
}
|
||||
|
||||
// String renders the card the way a table shows it: "A♠", "10♥".
|
||||
func (c Card) String() string {
|
||||
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
|
||||
return "??"
|
||||
}
|
||||
return rankNames[c.Rank] + suitGlyphs[c.Suit]
|
||||
}
|
||||
|
||||
// Red reports whether the card is a red suit — the one thing every renderer
|
||||
// needs and nobody should re-derive.
|
||||
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
|
||||
|
||||
// Deck is an ordered pile of cards. The next card to come off is at index 0.
|
||||
type Deck []Card
|
||||
|
||||
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
|
||||
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
|
||||
func NewDeck(n int) Deck {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
d := make(Deck, 0, 52*n)
|
||||
for i := 0; i < n; i++ {
|
||||
for s := Spades; s <= Clubs; s++ {
|
||||
for r := Ace; r <= King; r++ {
|
||||
d = append(d, Card{Rank: r, Suit: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
|
||||
// *rand.Rand gives the same shuffle every time, which is the whole point.
|
||||
func (d Deck) Shuffle(rng *rand.Rand) {
|
||||
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
|
||||
}
|
||||
|
||||
// Draw takes the top card. ok is false when the deck is spent; the caller
|
||||
// decides whether that means reshuffle or fold, because the two games that hit
|
||||
// it disagree.
|
||||
func (d *Deck) Draw() (c Card, ok bool) {
|
||||
if len(*d) == 0 {
|
||||
return Card{}, false
|
||||
}
|
||||
c = (*d)[0]
|
||||
*d = (*d)[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// Hand renders a run of cards for display: "A♠ 10♥".
|
||||
func Hand(cs []Card) string {
|
||||
s := ""
|
||||
for i, c := range cs {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += c.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
|
||||
// hand so a finished hand can be replayed exactly as it was dealt.
|
||||
func NewRNG(seed1, seed2 uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed1, seed2))
|
||||
}
|
||||
|
||||
func (s Suit) String() string {
|
||||
if s > Clubs {
|
||||
return "?"
|
||||
}
|
||||
return suitGlyphs[s]
|
||||
}
|
||||
102
internal/games/cards/cards_test.go
Normal file
102
internal/games/cards/cards_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package cards
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDeck_IsAFullShoe(t *testing.T) {
|
||||
d := NewDeck(6)
|
||||
if len(d) != 312 {
|
||||
t.Fatalf("six decks hold %d cards, want 312", len(d))
|
||||
}
|
||||
seen := map[Card]int{}
|
||||
for _, c := range d {
|
||||
seen[c]++
|
||||
}
|
||||
if len(seen) != 52 {
|
||||
t.Fatalf("%d distinct cards, want 52", len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 6 {
|
||||
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
|
||||
if len(NewDeck(0)) != 52 {
|
||||
t.Fatal("a zero-deck shoe should still hold one deck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_SameSeedSameOrder(t *testing.T) {
|
||||
a, b := NewDeck(1), NewDeck(1)
|
||||
a.Shuffle(NewRNG(99, 1))
|
||||
b.Shuffle(NewRNG(99, 1))
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
// And a different seed must not give the same order, or the RNG isn't wired up.
|
||||
c := NewDeck(1)
|
||||
c.Shuffle(NewRNG(100, 1))
|
||||
same := true
|
||||
for i := range a {
|
||||
if a[i] != c[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Fatal("a different seed produced an identical shuffle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_KeepsEveryCard(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
d.Shuffle(NewRNG(4, 4))
|
||||
seen := map[Card]bool{}
|
||||
for _, c := range d {
|
||||
seen[c] = true
|
||||
}
|
||||
if len(d) != 52 || len(seen) != 52 {
|
||||
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
top := d[0]
|
||||
c, ok := d.Draw()
|
||||
if !ok || c != top {
|
||||
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
|
||||
}
|
||||
if len(d) != 51 {
|
||||
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
|
||||
}
|
||||
for len(d) > 0 {
|
||||
d.Draw()
|
||||
}
|
||||
if _, ok := d.Draw(); ok {
|
||||
t.Fatal("an empty deck kept dealing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCard_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
card Card
|
||||
want string
|
||||
}{
|
||||
{Card{Ace, Spades}, "A♠"},
|
||||
{Card{10, Hearts}, "10♥"},
|
||||
{Card{King, Clubs}, "K♣"},
|
||||
{Card{Rank: 99, Suit: Spades}, "??"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.card.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
|
||||
t.Error("Red() disagrees about which suits are red")
|
||||
}
|
||||
}
|
||||
400
internal/games/hangman/hangman.go
Normal file
400
internal/games/hangman/hangman.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// Package hangman is a pure hangman engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack: ApplyMove(state, move) (state, events, error), where
|
||||
// an error means the move was illegal and nothing else. No HTTP, no timers, no
|
||||
// player names. The state is a plain value, so a game survives a redeploy and
|
||||
// replays from its seed.
|
||||
//
|
||||
// The casino version differs from the one gogobee plays in Matrix in one way
|
||||
// that matters: there is money on it. That makes the gallows a payout meter as
|
||||
// well as a death clock — every wrong guess takes a tenth off what a win is
|
||||
// worth. You can still win from five wrong, you just won't win much.
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("hangman: the game is already over")
|
||||
ErrNotALetter = errors.New("hangman: that is not a letter")
|
||||
ErrAlreadyTried = errors.New("hangman: that letter has been tried")
|
||||
ErrUnknownMove = errors.New("hangman: unknown move")
|
||||
ErrBadBet = errors.New("hangman: bet must be positive")
|
||||
ErrEmptySolution = errors.New("hangman: guess something")
|
||||
ErrUnknownTier = errors.New("hangman: no such tier")
|
||||
)
|
||||
|
||||
// MaxWrong is how many wrong guesses the gallows holds: head, body, two arms,
|
||||
// two legs. It is not a tier setting — the drawing has six parts, so the game
|
||||
// has six lives, and the tiers vary what a win pays instead.
|
||||
const MaxWrong = 6
|
||||
|
||||
// Decay is what one wrong guess costs, as a fraction of the tier's base
|
||||
// multiple. Six wrong is death, so the worst a living player can be is 50% off.
|
||||
const Decay = 0.10
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. Short phrases pay the most:
|
||||
// there is less of them to read, and fewer distinct letters to hit by accident.
|
||||
// A long phrase mostly reveals itself.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Min int `json:"min"` // phrase length, in characters
|
||||
Max int `json:"max"` // inclusive
|
||||
Base float64 `json:"base"` // what a win pays, before any wrong guesses
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables. The bank has 74 short phrases, 67 medium and 64
|
||||
// long, so none of them runs thin.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "short", Name: "Short", Min: 8, Max: 20, Base: 2.6,
|
||||
Blurb: "A handful of letters and nothing to go on."},
|
||||
{Slug: "medium", Name: "Medium", Min: 21, Max: 40, Base: 2.0,
|
||||
Blurb: "Long enough to read once it starts falling open."},
|
||||
{Slug: "long", Name: "Long", Min: 41, Max: 9999, Base: 1.6,
|
||||
Blurb: "It gives itself away. It also pays the least."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier 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
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeSolved Outcome = "solved" // guessed the phrase outright
|
||||
OutcomeFilled Outcome = "filled" // revealed the last letter
|
||||
OutcomeHung Outcome = "hung" // six wrong
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeSolved || o == OutcomeFilled }
|
||||
|
||||
// State is one game. The phrase is in here, which is exactly why this value
|
||||
// never leaves the server — the browser gets a Masked() view of it instead.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Phrase string `json:"phrase"`
|
||||
Runes []rune `json:"runes"` // the phrase, indexable safely
|
||||
Shown []bool `json:"shown"` // one per rune: is it face up
|
||||
Tried []rune `json:"tried"` // every letter guessed, in order, right or wrong
|
||||
Wrong []rune `json:"wrong"` // just the ones that missed
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates: a letter turning over, a limb being
|
||||
// drawn. The engine emits them rather than drawing anything itself.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "start" | "hit" | "miss" | "solve" | "settle"
|
||||
Letter string `json:"letter,omitempty"` // the letter guessed
|
||||
At []int `json:"at,omitempty"` // which rune positions it turned over
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: a single letter, or the whole phrase.
|
||||
type Move struct {
|
||||
Letter string `json:"letter"`
|
||||
Solve string `json:"solve"`
|
||||
}
|
||||
|
||||
// New starts a game on a phrase drawn from the tier's shelf of the bank.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
phrase, err := drawPhrase(t, rng)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
return start(bet, t, phrase, rakePct)
|
||||
}
|
||||
|
||||
// start builds the opening state for a known phrase. Split out from New so a
|
||||
// test can pin the phrase instead of the seed.
|
||||
func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
rs := []rune(phrase)
|
||||
s := State{
|
||||
Tier: t, Phrase: phrase, Runes: rs,
|
||||
Shown: make([]bool, len(rs)),
|
||||
RakePct: rakePct,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
// Spaces and punctuation are never guessed — they start face up, because a
|
||||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||
for i, r := range rs {
|
||||
if !Guessable(r) {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
}
|
||||
return s, []Event{{Kind: "start"}}, nil
|
||||
}
|
||||
|
||||
// Guessable reports whether a rune is one you'd guess: a letter or a digit.
|
||||
// Everything else is scaffolding and is shown from the start.
|
||||
//
|
||||
// Exported because the renderer needs the same answer — a rune you'd guess is a
|
||||
// rune that gets a tile to guess it into. Asking the drawing side to decide that
|
||||
// for itself is how you get a board with no tile for a letter the engine is
|
||||
// waiting on, and a phrase that cannot be finished.
|
||||
func Guessable(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new state and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
switch {
|
||||
case m.Solve != "":
|
||||
return applySolve(s, m.Solve)
|
||||
case m.Letter != "":
|
||||
return applyLetter(s, m.Letter)
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
}
|
||||
|
||||
// applyLetter guesses one letter.
|
||||
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
||||
if len(rs) != 1 || !Guessable(rs[0]) {
|
||||
return s, nil, ErrNotALetter
|
||||
}
|
||||
g := rs[0]
|
||||
if containsRune(s.Tried, g) {
|
||||
// Not a miss — a no-op. Charging a life for a letter the board already
|
||||
// shows you tried is punishing a mis-click, not a bad guess.
|
||||
return s, nil, ErrAlreadyTried
|
||||
}
|
||||
s.Tried = append(s.Tried, g)
|
||||
|
||||
var at []int
|
||||
for i, r := range s.Runes {
|
||||
if !s.Shown[i] && foldEq(r, g) {
|
||||
s.Shown[i] = true
|
||||
at = append(at, i)
|
||||
}
|
||||
}
|
||||
|
||||
evs := []Event{}
|
||||
if len(at) > 0 {
|
||||
evs = append(evs, Event{Kind: "hit", Letter: string(g), At: at})
|
||||
if s.filled() {
|
||||
s.settle(OutcomeFilled, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
s.Wrong = append(s.Wrong, g)
|
||||
evs = append(evs, Event{Kind: "miss", Letter: string(g)})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// applySolve guesses the whole phrase. Right, and it's over at the multiple you
|
||||
// still hold. Wrong, and it costs a life like any other bad guess — otherwise
|
||||
// solving would be a free roll you could spam until it landed.
|
||||
func applySolve(s State, attempt string) (State, []Event, error) {
|
||||
if strings.TrimSpace(attempt) == "" {
|
||||
return s, nil, ErrEmptySolution
|
||||
}
|
||||
evs := []Event{{Kind: "solve", Text: attempt}}
|
||||
|
||||
if normalize(attempt) == normalize(s.Phrase) {
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
s.settle(OutcomeSolved, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// A wrong solve is a miss with no letter attached to it.
|
||||
s.Wrong = append(s.Wrong, '·')
|
||||
evs = append(evs, Event{Kind: "miss", Text: attempt})
|
||||
if len(s.Wrong) >= MaxWrong {
|
||||
s.settle(OutcomeHung, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Multiple is what a win is worth right now: the tier's base, less a tenth of
|
||||
// it for every wrong guess so far. Floored at 1, so a win never hands back less
|
||||
// than the stake — a player who fought through five wrong guesses and got there
|
||||
// has not earned a loss.
|
||||
func (s State) Multiple() float64 {
|
||||
m := s.Tier.Base * (1 - Decay*float64(len(s.Wrong)))
|
||||
if m < 1 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Pays is what a win *right now* would actually put back on the player's stack:
|
||||
// the stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists because the felt shows this number while the game is still running,
|
||||
// and settle() is the only other thing that computes it. If the two ever
|
||||
// disagreed the table would be quoting a payout it doesn't honour — so settle
|
||||
// calls this rather than doing the sum a second time.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // a win never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
rake = 0
|
||||
}
|
||||
profit -= rake
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// Rake taken on a win right now — the other half of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple()))
|
||||
if total < s.Bet {
|
||||
return 0
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Lives is how many wrong guesses are left.
|
||||
func (s State) Lives() int { return MaxWrong - len(s.Wrong) }
|
||||
|
||||
// filled reports whether every guessable rune is face up.
|
||||
func (s State) filled() bool {
|
||||
for _, up := range s.Shown {
|
||||
if !up {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as blackjack: the rake comes out of
|
||||
// winnings, never out of the stake. A loss is never charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
|
||||
// The phrase goes face up when it's over, win or lose. Losing without ever
|
||||
// being told the answer is the one thing hangman must never do.
|
||||
for i := range s.Shown {
|
||||
s.Shown[i] = true
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Masked is the phrase as the player may see it: revealed runes as themselves,
|
||||
// hidden ones as an underscore. This — not Phrase — is what crosses the wire.
|
||||
func (s State) Masked() string {
|
||||
var b strings.Builder
|
||||
for i, r := range s.Runes {
|
||||
if s.Shown[i] {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clone deep-copies the slices, so a derived state shares no backing array with
|
||||
// the one it came from and a state can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Runes = append([]rune(nil), s.Runes...)
|
||||
s.Shown = append([]bool(nil), s.Shown...)
|
||||
s.Tried = append([]rune(nil), s.Tried...)
|
||||
s.Wrong = append([]rune(nil), s.Wrong...)
|
||||
return s
|
||||
}
|
||||
|
||||
// normalize flattens a phrase for comparison: case, spacing and punctuation all
|
||||
// stop mattering. "How are you gentlemen!!" is solved by "how are you gentlemen".
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if Guessable(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// foldEq compares two runes the way a guess should: case-insensitively.
|
||||
func foldEq(a, b rune) bool {
|
||||
return unicode.ToLower(a) == unicode.ToLower(b)
|
||||
}
|
||||
|
||||
func containsRune(rs []rune, r rune) bool {
|
||||
for _, x := range rs {
|
||||
if foldEq(x, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
369
internal/games/hangman/hangman_test.go
Normal file
369
internal/games/hangman/hangman_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tierShort is the tier most tests play on: base 2.6.
|
||||
func tierShort(t *testing.T) Tier {
|
||||
t.Helper()
|
||||
tr, err := TierBySlug("short")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// play runs a sequence of single-letter guesses against a pinned phrase.
|
||||
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
|
||||
t.Helper()
|
||||
s, _, err := start(bet, tierShort(t), phrase, rake)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range guesses {
|
||||
next, _, err := ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatalf("guess %q: %v", g, err)
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStartShowsScaffoldingOnly(t *testing.T) {
|
||||
s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "______ ____"; got != want {
|
||||
t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "start" {
|
||||
t.Errorf("events = %+v, want one start", evs)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
|
||||
// Punctuation shows from the start; a digit is guessable like a letter.
|
||||
s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := s.Masked(), "_____ _!"; got != want {
|
||||
t.Fatalf("masked = %q, want %q", got, want)
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Letter: "9"})
|
||||
if err != nil {
|
||||
t.Fatalf("guessing a digit: %v", err)
|
||||
}
|
||||
if got, want := next.Masked(), "_____ 9!"; got != want {
|
||||
t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitRevealsEveryOccurrence(t *testing.T) {
|
||||
// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
|
||||
s := play(t, "Blue Shell", 100, 0.05, "l")
|
||||
if got, want := s.Masked(), "_l__ ___ll"; got != want {
|
||||
t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
|
||||
}
|
||||
if s.Lives() != MaxWrong {
|
||||
t.Errorf("a hit cost a life: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissCostsALifeAndTheMultiple(t *testing.T) {
|
||||
tr := tierShort(t)
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
want := tr.Base * 0.9
|
||||
if got := s.Multiple(); got != want {
|
||||
t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z")
|
||||
_, _, err := ApplyMove(s, Move{Letter: "z"})
|
||||
if err != ErrAlreadyTried {
|
||||
t.Fatalf("err = %v, want ErrAlreadyTried", err)
|
||||
}
|
||||
// And the state the caller holds is untouched: a mis-click is not a life.
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("the refused move moved the state: lives = %d", s.Lives())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
|
||||
s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
|
||||
if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
|
||||
t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", s.Payout)
|
||||
}
|
||||
if s.Net() != -100 {
|
||||
t.Errorf("net = %d, want -100", s.Net())
|
||||
}
|
||||
if strings.Contains(s.Masked(), "_") {
|
||||
t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
|
||||
// "Blue Shell" — every distinct letter, no misses.
|
||||
s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
|
||||
if s.Outcome != OutcomeFilled {
|
||||
t.Fatalf("outcome = %s, want filled", s.Outcome)
|
||||
}
|
||||
// Base 2.6, no wrong guesses, no rake: 100 -> 260.
|
||||
if s.Payout != 260 {
|
||||
t.Errorf("payout = %d, want 260", s.Payout)
|
||||
}
|
||||
if s.Net() != 160 {
|
||||
t.Errorf("net = %d, want 160", s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two wrong first: 2.6 -> 2.08.
|
||||
for _, g := range []string{"z", "x"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Fatalf("outcome = %s, want solved", s.Outcome)
|
||||
}
|
||||
if s.Payout != 208 {
|
||||
t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Outcome != OutcomeSolved {
|
||||
t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSolveCostsALife(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lives() != MaxWrong-1 {
|
||||
t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Errorf("phase = %s, want playing", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
|
||||
// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
|
||||
long, err := TierBySlug("long")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Five clean misses. Every letter in the pangram is in the pangram, so the
|
||||
// only things guaranteed to miss it are digits.
|
||||
for _, g := range []string{"1", "2", "3", "4", "5"} {
|
||||
s, _, err = ApplyMove(s, Move{Letter: g})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := s.Multiple(); got != 1 {
|
||||
t.Fatalf("multiple = %v, want 1 — the floor", got)
|
||||
}
|
||||
s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != 100 {
|
||||
t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
|
||||
}
|
||||
if s.Rake != 0 {
|
||||
t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
// Total 260, profit 160, rake 5% = 8, so 252 comes back.
|
||||
if s.Rake != 8 {
|
||||
t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
|
||||
}
|
||||
if s.Payout != 252 {
|
||||
t.Errorf("payout = %d, want 252", s.Payout)
|
||||
}
|
||||
if s.Payout < s.Bet {
|
||||
t.Error("the rake ate into the stake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
|
||||
// The table shows Pays() while the game is still running. If that number and
|
||||
// the one settle() lands on ever came apart, the felt would be advertising a
|
||||
// payout the house doesn't honour. Walk a game and check they agree at every
|
||||
// step — including after a miss, which is where they'd drift.
|
||||
for _, wrong := range []int{0, 1, 2, 3} {
|
||||
s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
misses := []string{"z", "x", "q", "y"}
|
||||
for i := 0; i < wrong; i++ {
|
||||
s, _, err = ApplyMove(s, Move{Letter: misses[i]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quoted := s.Pays() // what the felt is telling the player right now
|
||||
|
||||
s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Payout != quoted {
|
||||
t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
|
||||
}
|
||||
if s.Payout != s.Bet+s.Net() {
|
||||
t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
|
||||
s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
|
||||
t.Errorf("err = %v, want ErrGameOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageGuessesAreRefused(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
t.Errorf("move %+v was accepted", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
|
||||
s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := s.Masked()
|
||||
if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Masked() != before {
|
||||
t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
|
||||
}
|
||||
if len(s.Tried) != 0 {
|
||||
t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
|
||||
// A redeploy mid-game is a JSON round-trip. It has to come back playable.
|
||||
s := play(t, "Insert Coin", 100, 0.05, "i", "z")
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
|
||||
t.Fatalf("round trip changed the game: %+v", back)
|
||||
}
|
||||
next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Outcome != OutcomeSolved {
|
||||
t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsReproducibleFromItsSeed(t *testing.T) {
|
||||
// The seed is in the audit log so a disputed game can be replayed. That is
|
||||
// only true if the phrase comes back the same.
|
||||
one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Phrase != two.Phrase {
|
||||
t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDrawsFromTheRightShelf(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
for _, tr := range Tiers {
|
||||
for i := 0; i < 50; i++ {
|
||||
s, _, err := New(100, tr, 0.05, rng)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tr.Slug, err)
|
||||
}
|
||||
if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
|
||||
t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
|
||||
// If someone edits phrases.txt and empties a tier, the game 500s at the
|
||||
// table rather than here. Catch it here.
|
||||
for _, tr := range Tiers {
|
||||
if n := Shelf(tr.Slug); n < 20 {
|
||||
t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/games/hangman/phrases.go
Normal file
68
internal/games/hangman/phrases.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package hangman
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The bank. gogobee kept its phrases in a file it read at boot out of a path in
|
||||
// an env var, which meant the game was one missing file away from not existing.
|
||||
// Embedding it means the casino cannot start without its phrases, which is the
|
||||
// correct relationship between a game and the thing it is about.
|
||||
//
|
||||
//go:embed phrases.txt
|
||||
var phrasesTxt string
|
||||
|
||||
// ErrNoPhrases means the bank has nothing at this length. It can only happen if
|
||||
// someone edits phrases.txt down past a tier, and it is a programming error
|
||||
// rather than anything a player did — but it's an error, not a panic, because a
|
||||
// casino that won't boot is worse than one game being shut.
|
||||
var ErrNoPhrases = errors.New("hangman: no phrases in that tier")
|
||||
|
||||
var (
|
||||
shelvesOnce sync.Once
|
||||
shelves map[string][]string // tier slug -> the phrases that fit it
|
||||
)
|
||||
|
||||
// load sorts the bank onto one shelf per tier, once. Comments and blank lines
|
||||
// are dropped, and so is anything too short to be a game — the tiers' own Min
|
||||
// is the floor.
|
||||
func load() {
|
||||
shelves = make(map[string][]string, len(Tiers))
|
||||
sc := bufio.NewScanner(strings.NewReader(phrasesTxt))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
n := len([]rune(line))
|
||||
for _, t := range Tiers {
|
||||
if n >= t.Min && n <= t.Max {
|
||||
shelves[t.Slug] = append(shelves[t.Slug], line)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shelf is how many phrases a tier has. Exists so a test can assert the bank
|
||||
// hasn't been edited out from under a tier.
|
||||
func Shelf(slug string) int {
|
||||
shelvesOnce.Do(load)
|
||||
return len(shelves[slug])
|
||||
}
|
||||
|
||||
// drawPhrase picks one phrase from a tier's shelf. The rng is threaded, never
|
||||
// the package global, so a game replays exactly from the seed in its audit row.
|
||||
func drawPhrase(t Tier, rng *rand.Rand) (string, error) {
|
||||
shelvesOnce.Do(load)
|
||||
shelf := shelves[t.Slug]
|
||||
if len(shelf) == 0 {
|
||||
return "", ErrNoPhrases
|
||||
}
|
||||
return shelf[rng.IntN(len(shelf))], nil
|
||||
}
|
||||
237
internal/games/hangman/phrases.txt
Normal file
237
internal/games/hangman/phrases.txt
Normal file
@@ -0,0 +1,237 @@
|
||||
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||
#
|
||||
# Tiers are assigned automatically by character count at load time.
|
||||
# Section headers below are for human readability only -- the bot ignores them.
|
||||
#
|
||||
# Easy: 8-20 characters
|
||||
# Medium: 21-40 characters
|
||||
# Hard: 41-80 characters
|
||||
#
|
||||
# Add community phrases via: !hangman submit [phrase]
|
||||
# All submissions require LLM approval before entering the pool.
|
||||
# This file can be edited directly. Bot reloads on restart.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EASY (8-20 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Finish Him!
|
||||
Game Over
|
||||
Insert Coin
|
||||
Hadouken!
|
||||
Fatality!
|
||||
Big Boss
|
||||
Konami Code
|
||||
Warp Zone
|
||||
Blue Shell
|
||||
God Mode
|
||||
BFG 9000
|
||||
Cacodemon
|
||||
Quad Damage
|
||||
Samus Aran
|
||||
Morph Ball
|
||||
Mother Brain
|
||||
Dracula!
|
||||
Simon Belmont
|
||||
Ecclesia
|
||||
Outer Heaven
|
||||
Spread Gun
|
||||
Power Pellet
|
||||
Checkpoint
|
||||
Rocket Jump
|
||||
Mushroom Kingdom
|
||||
Princess Peach
|
||||
Bowser's Castle
|
||||
Fire Flower
|
||||
Varia Suit
|
||||
Space Jump
|
||||
Vampire Killer
|
||||
Holy Water
|
||||
Trevor Belmont
|
||||
Soma Cruz
|
||||
Julius Belmont
|
||||
Waluigi!
|
||||
Richter!
|
||||
Phantoon
|
||||
Speed Run
|
||||
High Score
|
||||
Continue?
|
||||
Press Start
|
||||
Ryu Hayabusa
|
||||
Plasma Gun
|
||||
What is a man?
|
||||
Serious Sam
|
||||
Shoryuken!
|
||||
Duck Hunt
|
||||
The cake is a lie
|
||||
War never changes
|
||||
Would you kindly?
|
||||
Do a barrel roll!
|
||||
Praise the Sun!
|
||||
A winner is you
|
||||
You're pretty good
|
||||
La-Li-Lu-Le-Lo
|
||||
I am error
|
||||
Leeroy Jenkins!
|
||||
For the Horde!
|
||||
For the Alliance!
|
||||
Frostmourne hungers
|
||||
Falcon Punch!
|
||||
Rip and tear!
|
||||
Vic Viper
|
||||
Salamander!
|
||||
Parodius Da!
|
||||
We'll bang, okay?
|
||||
What you say!!
|
||||
You spoony bard!
|
||||
One-Winged Angel
|
||||
Morning Star
|
||||
Glyph Union
|
||||
TwinBee, scramble!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIUM (21-40 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Stay a while and listen
|
||||
It's dangerous to go alone!
|
||||
Kept you waiting, huh?
|
||||
A man chooses, a slave obeys
|
||||
Metal Gear?! Metal Gear!!
|
||||
We're not tools of the government
|
||||
The winds of destruction
|
||||
Who are the Patriots?
|
||||
This is good, isn't it?
|
||||
You have died of dysentery
|
||||
The Triforce of Courage
|
||||
Ganon has broken the seal!
|
||||
May the wind guide you home
|
||||
Dodongo dislikes smoke
|
||||
The right man in the wrong place
|
||||
Nothing is true, everything is permitted
|
||||
I used to be an adventurer like you
|
||||
You can't hide from the Grim Reaper
|
||||
All your base are belong to us!
|
||||
Somebody set up us the bomb!
|
||||
For great justice, take off every Zig!
|
||||
How are you gentlemen!!
|
||||
Kain has betrayed us!
|
||||
Garland will knock you all down!
|
||||
You are not prepared!
|
||||
I am Uther the Lightbringer!
|
||||
Order of Ecclesia calls
|
||||
The Dark Lord rises again!
|
||||
Shanoa, bearer of glyphs
|
||||
In this world, it's kill or be killed
|
||||
Despite everything, it's still you
|
||||
The Underground is your home now
|
||||
Papyrus demands a battle!
|
||||
I'm going to make spaghetti!
|
||||
Toriel will protect you
|
||||
Estus Flask replenished
|
||||
The age of fire fades
|
||||
Prepare to die, undead one
|
||||
Can't let you do that, Star Fox!
|
||||
Andross' empire spans the Lylat system!
|
||||
Captain Falcon, show me your moves!
|
||||
OBJECTION! That testimony is a lie!
|
||||
Hold it! I have new evidence!
|
||||
Phoenix Wright, attorney at law!
|
||||
Does this unit have a soul?
|
||||
Shepard, the Reapers are coming!
|
||||
Tali'Zorah vas Normandy!
|
||||
Gruntilda shall not be defeated!
|
||||
K. Rool has stolen the banana hoard!
|
||||
Kirby, hero of Dream Land!
|
||||
Meta Knight awaits your challenge!
|
||||
Congraturation! This story is happy end.
|
||||
Cecil has become a Paladin
|
||||
Cloud Strife, SOLDIER First Class
|
||||
Time compression is inevitable
|
||||
You require more vespene gas
|
||||
Nuclear launch detected
|
||||
You must construct additional pylons
|
||||
I'm Commander Shepard!
|
||||
War... War has changed.
|
||||
Rip and tear until it is done!
|
||||
Dawn of Sorrow awaits
|
||||
Do you feel like a hero yet?
|
||||
You're a monster. You know that, Walker?
|
||||
Halo... it's not a natural formation.
|
||||
Whip it good, Belmont!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HARD (41-80 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
What is a man? A miserable pile of secrets!
|
||||
Die monster! You don't belong in this world!
|
||||
My name is Dracula, and I bid you welcome.
|
||||
You have no chance to survive make your time!
|
||||
You've met with a terrible fate, haven't you?
|
||||
Fear the old blood... and welcome, good hunter.
|
||||
Welcome to the Liandri Grand Tournament!
|
||||
I am the very model of a scientist Salarian!
|
||||
Metroid Prime has escaped into the impact crater!
|
||||
I want to be the very best, like no one ever was
|
||||
The world needs only one Big Boss... and one Snake.
|
||||
Snake, do you think love can bloom on a battlefield?
|
||||
Thank you Mario, but our princess is in another castle!
|
||||
You must gather your party before venturing forth
|
||||
Snake, we're not tools of the government, or anyone else
|
||||
Did I ever tell you what the definition of insanity is?
|
||||
They're everywhere! The demons... they won't stop coming!
|
||||
Dracula! Your time has come! The Vampire Killer strikes!
|
||||
Link... I'm Navi, your fairy companion! Listen!
|
||||
Hero of Time, your destiny awaits in the Sacred Realm
|
||||
Master Chief, finish the fight. Earth is counting on you.
|
||||
Outer Heaven... a place where warriors can find purpose
|
||||
We passed the point of no return a long time ago, Snake
|
||||
Liquid! I was the one who was meant to be the successor!
|
||||
Revolver Ocelot is a triple agent working for the Patriots
|
||||
Phazon corruption detected! Seek immediate medical attention!
|
||||
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||
Praise the Chosen Undead, for they shall link the fire!
|
||||
Ganon is the evil king who stole the Triforce of Power!
|
||||
The legendary soldier who defied his genes... Big Boss
|
||||
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||
Humanity restored! The bonfire blazes with newfound strength!
|
||||
You are the last line of defense against an infinite demonic army
|
||||
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||
All we did was give meaning to the nuclear age by using nukes!
|
||||
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||
You are the Chosen Undead, fated to succeed where so many have failed
|
||||
The price of living in the past is a slow death in the present
|
||||
A sword wields no strength unless the hands that hold it have courage
|
||||
The flow of time is always cruel, its speed seems different for each person
|
||||
Hey! Listen! There's something important you need to know!
|
||||
We are born of the blood, made men by the blood, undone by the blood
|
||||
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
I used to be an adventurer like you, then I took an arrow in the knee
|
||||
I'm not the only one who's responsible. The humans are just as guilty!
|
||||
The Skaarj have invaded, and you must fight your way to freedom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||
I need scissors! 61!
|
||||
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||
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)) }
|
||||
684
internal/games/klondike/klondike.go
Normal file
684
internal/games/klondike/klondike.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Package klondike is a pure Klondike solitaire engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move) (state, events,
|
||||
// error), where an error means the move was illegal and nothing else. The state
|
||||
// is a plain value, so a game survives a redeploy and replays from its seed.
|
||||
//
|
||||
// The casino version is Vegas scoring, which is the only way solitaire has ever
|
||||
// been a gambling game and the only shape that makes sense with money on it.
|
||||
// You do not win or lose the deal. You *buy the deck* for your stake, and every
|
||||
// card you get home to a foundation pays a slice of it back. Fifty-two cards
|
||||
// home pays the tier's full multiple; nothing home pays nothing. You can stop
|
||||
// whenever you like and keep what you have banked, which is what makes a game
|
||||
// that has gone dead a decision rather than a wall.
|
||||
//
|
||||
// There is no undo. The stake is spent the moment the deck is bought, so an undo
|
||||
// would be a way to walk a losing board backwards until it wins.
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("klondike: the game is already over")
|
||||
ErrUnknownMove = errors.New("klondike: unknown move")
|
||||
ErrBadBet = errors.New("klondike: bet must be positive")
|
||||
ErrUnknownTier = errors.New("klondike: no such tier")
|
||||
ErrBadPile = errors.New("klondike: no such pile")
|
||||
ErrEmptyPile = errors.New("klondike: there is nothing there to move")
|
||||
ErrNotASequence = errors.New("klondike: those cards aren't a run you can lift")
|
||||
ErrWontGo = errors.New("klondike: that card doesn't go there")
|
||||
ErrNoDraw = errors.New("klondike: there is nothing left to turn over")
|
||||
ErrNoPasses = errors.New("klondike: you've used your passes through the stock")
|
||||
ErrNothingHome = errors.New("klondike: nothing can go home right now")
|
||||
)
|
||||
|
||||
// Piles is the number of tableau columns. Foundations is one per suit.
|
||||
const (
|
||||
Piles = 7
|
||||
Foundations = 4
|
||||
FullDeck = 52
|
||||
)
|
||||
|
||||
// Tier is a difficulty, chosen with the bet. The two dials are how many cards
|
||||
// the stock turns over at a time and how many times you may go through it —
|
||||
// which between them are the whole difficulty of Klondike. Turning three at a
|
||||
// time hides two of every three cards behind a card you may never reach; a
|
||||
// single pass means the ones you leave behind are gone for good.
|
||||
//
|
||||
// The multiple pays for that. Cutthroat is the cruellest deal in the room and
|
||||
// pays 3.4×, which means you are ahead from sixteen cards home even though most
|
||||
// of those boards never clear.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Draw int `json:"draw"` // cards turned over per pull on the stock
|
||||
Passes int `json:"passes"` // times through the stock; 0 means unlimited
|
||||
Base float64 `json:"base"` // what a full 52 cards home pays, as a multiple of the stake
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// BreakEven is how many cards have to reach the foundations before the player is
|
||||
// square with the house. It's the number the felt actually quotes, because
|
||||
// "1.4×" tells a player nothing about a game where the multiple is paid per card.
|
||||
func (t Tier) BreakEven() int {
|
||||
if t.Base <= 0 {
|
||||
return FullDeck
|
||||
}
|
||||
n := int(math.Ceil(float64(FullDeck) / t.Base))
|
||||
if n > FullDeck {
|
||||
return FullDeck
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Tiers are the three deals.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "patient", Name: "Patient", Draw: 1, Passes: 0, Base: 1.4,
|
||||
Blurb: "One card at a time, through the stock as often as you like."},
|
||||
{Slug: "vegas", Name: "Vegas", Draw: 3, Passes: 3, Base: 2.2,
|
||||
Blurb: "Three at a time, three times round. The house game."},
|
||||
{Slug: "cutthroat", Name: "Cutthroat", Draw: 3, Passes: 1, Base: 3.4,
|
||||
Blurb: "Three at a time, one pass. What you leave behind is gone."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier 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
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended. Note there is no "lost": a board that goes dead is
|
||||
// cashed, for whatever it made. Solitaire's failure mode is a board you can't
|
||||
// improve, and the honest thing to do with one is pay out what's on it.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeCleared Outcome = "cleared" // all 52 home
|
||||
OutcomeCashed Outcome = "cashed" // the player stopped and took the board
|
||||
)
|
||||
|
||||
// Pile is one tableau column: a face-down stack with a face-up run on top of it.
|
||||
// Down is the part the browser never sees.
|
||||
type Pile struct {
|
||||
Down []cards.Card `json:"down"`
|
||||
Up []cards.Card `json:"up"`
|
||||
}
|
||||
|
||||
// State is one game. The stock and every Down card are in here, which is exactly
|
||||
// why this value never leaves the server.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Stock cards.Deck `json:"stock"`
|
||||
Waste []cards.Card `json:"waste"`
|
||||
Table [Piles]Pile `json:"table"`
|
||||
Found [Foundations][]cards.Card `json:"found"` // indexed by suit
|
||||
Recycles int `json:"recycles"` // times the waste has gone back under
|
||||
Moves int `json:"moves"`
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates. The engine emits them rather than
|
||||
// leaving the browser to diff two boards and guess what moved — a card that
|
||||
// slides from a column to a foundation and a card that was simply redrawn there
|
||||
// are the same diff and very different things to watch.
|
||||
//
|
||||
// Home and Pays ride on every event, so the meter on the felt is always quoting
|
||||
// a number the engine worked out. The browser never does this arithmetic: it did
|
||||
// once, and the felt advertised a payout the house didn't honour.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "deal" | "draw" | "recycle" | "move" | "home" | "flip" | "settle"
|
||||
Cards []cards.Card `json:"cards,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Home int `json:"home"`
|
||||
Pays int64 `json:"pays"`
|
||||
}
|
||||
|
||||
// Move is a player action.
|
||||
//
|
||||
// Home is its own kind rather than a Move To a foundation the player picked,
|
||||
// because there is only ever one foundation a card can go to and asking the
|
||||
// player to name it would be a quiz about suit ordering. The browser sends
|
||||
// "this card, home"; the engine finds the pile.
|
||||
type Move struct {
|
||||
Kind string `json:"kind"` // "draw" | "move" | "home" | "auto" | "concede"
|
||||
From string `json:"from"` // "waste" | "t0".."t6" | "f0".."f3"
|
||||
To string `json:"to"` // "t0".."t6" | "f0".."f3"
|
||||
Count int `json:"count"` // how many cards off the end of a tableau run; 0 means 1
|
||||
}
|
||||
|
||||
// New deals a game.
|
||||
func New(bet int64, t Tier, rakePct float64, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if t.Draw < 1 {
|
||||
return State{}, nil, ErrUnknownTier
|
||||
}
|
||||
d := cards.NewDeck(1)
|
||||
d.Shuffle(rng)
|
||||
return deal(bet, t, d, rakePct)
|
||||
}
|
||||
|
||||
// deal lays the board out. Split out from New so a test can pin the deck
|
||||
// instead of the seed.
|
||||
func deal(bet int64, t Tier, d cards.Deck, rakePct float64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(d) != FullDeck {
|
||||
return State{}, nil, errors.New("klondike: a solitaire deck is 52 cards")
|
||||
}
|
||||
s := State{Tier: t, Bet: bet, RakePct: rakePct, Phase: PhasePlaying}
|
||||
|
||||
// The classic lay-out: column i gets i+1 cards, the last of them face up.
|
||||
for i := 0; i < Piles; i++ {
|
||||
for j := 0; j <= i; j++ {
|
||||
c, _ := d.Draw()
|
||||
if j == i {
|
||||
s.Table[i].Up = append(s.Table[i].Up, c)
|
||||
} else {
|
||||
s.Table[i].Down = append(s.Table[i].Down, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Stock = d
|
||||
return s, []Event{s.event("deal", nil, "", "")}, nil
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. A legal move in, the new board and what happened out.
|
||||
// An error means the move was illegal and the caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
// The move is played against a copy, and an illegal one hands the original
|
||||
// back untouched. Nothing below mutates before it has decided the move is
|
||||
// legal — but "nothing below mutates early" is an invariant seven functions
|
||||
// have to keep, and this is one line that doesn't need them to.
|
||||
orig := s
|
||||
s = s.clone()
|
||||
|
||||
var evs []Event
|
||||
var err error
|
||||
switch m.Kind {
|
||||
case "draw":
|
||||
evs, err = s.draw()
|
||||
case "move":
|
||||
evs, err = s.move(m.From, m.To, m.Count)
|
||||
case "home":
|
||||
evs, err = s.home(m.From)
|
||||
case "auto":
|
||||
evs, err = s.auto()
|
||||
case "concede":
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
return s, evs, nil
|
||||
default:
|
||||
return orig, nil, ErrUnknownMove
|
||||
}
|
||||
if err != nil {
|
||||
return orig, nil, err
|
||||
}
|
||||
s.Moves++
|
||||
|
||||
// A cleared board settles itself. Nothing else does: a board with no move left
|
||||
// on it is not something the engine gets to decide, because "no move left" in
|
||||
// Klondike depends on cards nobody has turned over yet.
|
||||
if s.cleared() {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// ---- the moves -------------------------------------------------------------
|
||||
|
||||
// draw turns cards off the stock, or puts the waste back under it if the stock
|
||||
// is spent and the tier still owes a pass.
|
||||
func (s *State) draw() ([]Event, error) {
|
||||
if len(s.Stock) == 0 {
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrNoDraw
|
||||
}
|
||||
// Passes is how many times you may go *through* the stock, so the number of
|
||||
// times you may turn it back over is one less than that. Zero means unlimited.
|
||||
if s.Tier.Passes > 0 && s.Recycles >= s.Tier.Passes-1 {
|
||||
return nil, ErrNoPasses
|
||||
}
|
||||
// The waste is turned over as a block, not reshuffled — so the card that
|
||||
// comes out first on the next pass is the one that came out first on this
|
||||
// one. Which means no reversal: the waste's *bottom* card is the one your
|
||||
// hand lands on when you flip the pile, and the bottom card is the one that
|
||||
// was drawn first. Reversing here would deal a different game on every pass
|
||||
// and quietly break the seed in the audit log.
|
||||
s.Stock = cards.Deck(s.Waste)
|
||||
s.Waste = nil
|
||||
s.Recycles++
|
||||
return []Event{s.event("recycle", nil, "waste", "stock")}, nil
|
||||
}
|
||||
|
||||
n := s.Tier.Draw
|
||||
if n > len(s.Stock) {
|
||||
n = len(s.Stock)
|
||||
}
|
||||
drawn := make([]cards.Card, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c, _ := s.Stock.Draw()
|
||||
drawn = append(drawn, c)
|
||||
s.Waste = append(s.Waste, c)
|
||||
}
|
||||
return []Event{s.event("draw", drawn, "stock", "waste")}, nil
|
||||
}
|
||||
|
||||
// move takes cards from one pile and puts them on another.
|
||||
func (s *State) move(from, to string, count int) ([]Event, error) {
|
||||
if count < 1 {
|
||||
count = 1
|
||||
}
|
||||
lifted, err := s.peek(from, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.accepts(to, lifted) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
if err := s.take(from, count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.put(to, lifted)
|
||||
|
||||
kind := "move"
|
||||
if isFoundation(to) {
|
||||
kind = "home"
|
||||
}
|
||||
evs := []Event{s.event(kind, lifted, from, to)}
|
||||
return s.withFlip(from, evs), nil
|
||||
}
|
||||
|
||||
// home sends the top card of a pile to the foundation that will take it. There
|
||||
// is only ever one, so the player doesn't have to say which.
|
||||
func (s *State) home(from string) ([]Event, error) {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
return nil, ErrWontGo
|
||||
}
|
||||
return s.move(from, to, 1)
|
||||
}
|
||||
|
||||
// auto sends everything that can go home, home, and keeps doing it until nothing
|
||||
// else can. It is the finish button, and it is also the shortcut for the tail of
|
||||
// a board that is already decided.
|
||||
//
|
||||
// It can cost you: a two you needed on the tableau is a two that has gone home.
|
||||
// That is the player's call to make by pressing it, and it is the same call the
|
||||
// button makes in every other solitaire ever written.
|
||||
func (s *State) auto() ([]Event, error) {
|
||||
var evs []Event
|
||||
for {
|
||||
moved := false
|
||||
for _, from := range sources() {
|
||||
top, err := s.peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
to := "f" + strconv.Itoa(int(top[0].Suit))
|
||||
if !s.accepts(to, top) {
|
||||
continue
|
||||
}
|
||||
one, err := s.move(from, to, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
evs = append(evs, one...)
|
||||
moved = true
|
||||
}
|
||||
if !moved {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
return nil, ErrNothingHome
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// sources are the piles auto() will lift a card off, in the order it tries them.
|
||||
func sources() []string {
|
||||
out := make([]string, 0, Piles+1)
|
||||
out = append(out, "waste")
|
||||
for i := 0; i < Piles; i++ {
|
||||
out = append(out, "t"+strconv.Itoa(i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withFlip turns up the card a tableau column was hiding, if taking from it left
|
||||
// its face-down stack exposed. This is the only thing in the game that reveals a
|
||||
// card the player hadn't earned yet, so it is the only place it can happen.
|
||||
func (s *State) withFlip(from string, evs []Event) []Event {
|
||||
i, ok := tableauIndex(from)
|
||||
if !ok {
|
||||
return evs
|
||||
}
|
||||
p := &s.Table[i]
|
||||
if len(p.Up) > 0 || len(p.Down) == 0 {
|
||||
return evs
|
||||
}
|
||||
c := p.Down[len(p.Down)-1]
|
||||
p.Down = p.Down[:len(p.Down)-1]
|
||||
p.Up = append(p.Up, c)
|
||||
return append(evs, s.event("flip", []cards.Card{c}, from, from))
|
||||
}
|
||||
|
||||
// ---- piles -----------------------------------------------------------------
|
||||
|
||||
// peek returns the top `count` cards of a pile without taking them, and refuses
|
||||
// a run that isn't one you could lift: a tableau run has to descend in rank and
|
||||
// alternate colour all the way down, exactly as it does on the felt.
|
||||
func (s *State) peek(name string, count int) ([]cards.Card, error) {
|
||||
switch {
|
||||
case name == "waste":
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence // the waste is a pile, not a run: one card, the top one
|
||||
}
|
||||
if len(s.Waste) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{s.Waste[len(s.Waste)-1]}, nil
|
||||
|
||||
case isFoundation(name):
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
f := s.Found[i]
|
||||
if len(f) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
return []cards.Card{f[len(f)-1]}, nil
|
||||
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return nil, ErrBadPile
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
return nil, ErrEmptyPile
|
||||
}
|
||||
if count > len(up) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
run := up[len(up)-count:]
|
||||
if !isRun(run) {
|
||||
return nil, ErrNotASequence
|
||||
}
|
||||
return append([]cards.Card(nil), run...), nil
|
||||
}
|
||||
}
|
||||
|
||||
// take removes the top `count` cards. peek has already vetted them.
|
||||
func (s *State) take(name string, count int) error {
|
||||
switch {
|
||||
case name == "waste":
|
||||
s.Waste = s.Waste[:len(s.Waste)-count]
|
||||
return nil
|
||||
case isFoundation(name):
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = s.Found[i][:len(s.Found[i])-count]
|
||||
return nil
|
||||
default:
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return ErrBadPile
|
||||
}
|
||||
s.Table[i].Up = s.Table[i].Up[:len(s.Table[i].Up)-count]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// put drops cards onto a pile. accepts has already vetted them.
|
||||
func (s *State) put(name string, cs []cards.Card) {
|
||||
if isFoundation(name) {
|
||||
i, _ := foundationIndex(name)
|
||||
s.Found[i] = append(s.Found[i], cs...)
|
||||
return
|
||||
}
|
||||
i, _ := tableauIndex(name)
|
||||
s.Table[i].Up = append(s.Table[i].Up, cs...)
|
||||
}
|
||||
|
||||
// accepts is the rule the whole game is made of: what may be put where.
|
||||
//
|
||||
// A foundation takes its own suit in order from the ace, one card at a time. A
|
||||
// tableau column takes a run that descends by one and alternates colour from its
|
||||
// top card, and an empty column takes a King and nothing else.
|
||||
func (s *State) accepts(name string, cs []cards.Card) bool {
|
||||
if len(cs) == 0 {
|
||||
return false
|
||||
}
|
||||
if isFoundation(name) {
|
||||
i, ok := foundationIndex(name)
|
||||
if !ok || len(cs) != 1 {
|
||||
return false
|
||||
}
|
||||
c := cs[0]
|
||||
return int(c.Suit) == i && int(c.Rank) == len(s.Found[i])+1
|
||||
}
|
||||
|
||||
i, ok := tableauIndex(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !isRun(cs) {
|
||||
return false
|
||||
}
|
||||
up := s.Table[i].Up
|
||||
if len(up) == 0 {
|
||||
// An empty column is the most valuable thing on the board, so it costs a
|
||||
// King to take one. A column with cards still face-down under it is not
|
||||
// empty, and Up being empty there can't happen: withFlip turns one over.
|
||||
return cs[0].Rank == cards.King && len(s.Table[i].Down) == 0
|
||||
}
|
||||
top := up[len(up)-1]
|
||||
return int(cs[0].Rank) == int(top.Rank)-1 && cs[0].Red() != top.Red()
|
||||
}
|
||||
|
||||
// isRun reports whether these cards, in this order, are a tableau sequence:
|
||||
// descending by one, alternating colour.
|
||||
func isRun(cs []cards.Card) bool {
|
||||
for i := 1; i < len(cs); i++ {
|
||||
if int(cs[i].Rank) != int(cs[i-1].Rank)-1 || cs[i].Red() == cs[i-1].Red() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFoundation(name string) bool { return strings.HasPrefix(name, "f") }
|
||||
|
||||
func tableauIndex(name string) (int, bool) { return pileIndex(name, "t", Piles) }
|
||||
|
||||
func foundationIndex(name string) (int, bool) { return pileIndex(name, "f", Foundations) }
|
||||
|
||||
func pileIndex(name, prefix string, n int) (int, bool) {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
i, err := strconv.Atoi(name[len(prefix):])
|
||||
if err != nil || i < 0 || i >= n {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// Home is how many cards have reached the foundations. It is the only number in
|
||||
// this game that the payout depends on.
|
||||
func (s State) Home() int {
|
||||
n := 0
|
||||
for _, f := range s.Found {
|
||||
n += len(f)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PerCard is what one card home is worth, before the rake. The felt quotes this
|
||||
// because "2.2×" tells a player nothing about a game where the multiple is paid
|
||||
// out a fifty-second at a time.
|
||||
func (s State) PerCard() float64 {
|
||||
return float64(s.Bet) * s.Tier.Base / float64(FullDeck)
|
||||
}
|
||||
|
||||
// Earned is the gross: what the cards home have bought back, before the house
|
||||
// takes anything. Computed from the total rather than card by card, so 52 cards
|
||||
// home pays the tier's multiple exactly instead of the multiple less 52 roundings.
|
||||
func (s State) Earned() int64 {
|
||||
return int64(math.Floor(float64(s.Bet) * s.Tier.Base * float64(s.Home()) / float64(FullDeck)))
|
||||
}
|
||||
|
||||
// Pays is what stopping *right now* would actually put back on the player's
|
||||
// stack: the gross, less the house's cut of anything above the stake.
|
||||
//
|
||||
// The felt shows this number while the game is still running and settle() lands
|
||||
// on it, and they are the same function for the reason hangman's are: the moment
|
||||
// they are two sums, the table is quoting a payout it doesn't honour.
|
||||
//
|
||||
// Unlike the other games it can be less than the stake, and can be zero. That is
|
||||
// the game — you bought the deck, and a deck that gives you nothing owes you
|
||||
// nothing.
|
||||
func (s State) Pays() int64 {
|
||||
total := s.Earned()
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
total -= rake
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// rakeNow is the house's cut if the board were cashed right now — the other half
|
||||
// of what Pays works out.
|
||||
func (s State) rakeNow() int64 {
|
||||
profit := s.Earned() - s.Bet
|
||||
if profit <= 0 {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// cleared reports whether every card is home.
|
||||
func (s State) cleared() bool { return s.Home() == FullDeck }
|
||||
|
||||
// CanAuto reports whether anything can go home at all — which is what greys the
|
||||
// finish button out rather than letting it be pressed at a board that has nothing
|
||||
// for it.
|
||||
func (s State) CanAuto() bool {
|
||||
for _, from := range sources() {
|
||||
top, err := (&s).peek(from, 1)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if (&s).accepts("f"+strconv.Itoa(int(top[0].Suit)), top) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PassesLeft is how many more times the player may go through the stock,
|
||||
// counting the one they are in. -1 means unlimited.
|
||||
func (s State) PassesLeft() int {
|
||||
if s.Tier.Passes <= 0 {
|
||||
return -1
|
||||
}
|
||||
left := s.Tier.Passes - s.Recycles
|
||||
if left < 0 {
|
||||
return 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
// settle closes the game at whatever is on the board. Same rule as everywhere
|
||||
// else in the room: the rake comes out of winnings, never out of the stake.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
*evs = append(*evs, s.event("settle", nil, "", string(o)))
|
||||
}
|
||||
|
||||
// event stamps an event with the two numbers the felt's meter reads off it, so
|
||||
// the browser never has to work out what the board is worth.
|
||||
func (s State) event(kind string, cs []cards.Card, from, to string) Event {
|
||||
return Event{
|
||||
Kind: kind, Cards: cs, From: from, To: to,
|
||||
Home: s.Home(), Pays: s.Pays(),
|
||||
}
|
||||
}
|
||||
|
||||
// clone deep-copies everything with a backing array, so a derived state shares
|
||||
// none of it with the one it came from and a board can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Stock = append(cards.Deck(nil), s.Stock...)
|
||||
s.Waste = append([]cards.Card(nil), s.Waste...)
|
||||
for i := range s.Table {
|
||||
s.Table[i].Down = append([]cards.Card(nil), s.Table[i].Down...)
|
||||
s.Table[i].Up = append([]cards.Card(nil), s.Table[i].Up...)
|
||||
}
|
||||
for i := range s.Found {
|
||||
s.Found[i] = append([]cards.Card(nil), s.Found[i]...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
730
internal/games/klondike/klondike_test.go
Normal file
730
internal/games/klondike/klondike_test.go
Normal file
@@ -0,0 +1,730 @@
|
||||
package klondike
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
func vegas() Tier { t, _ := TierBySlug("vegas"); return t }
|
||||
func patient() Tier { t, _ := TierBySlug("patient"); return t }
|
||||
func cut() Tier { t, _ := TierBySlug("cutthroat"); return t }
|
||||
|
||||
func card(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
// ordered builds the 52 cards in a fixed order — the deck deal() would get if
|
||||
// the shuffle were the identity. Tests that care about the board build their own.
|
||||
func ordered() cards.Deck { return cards.NewDeck(1) }
|
||||
|
||||
func mustDeal(t *testing.T, bet int64, tier Tier, d cards.Deck) State {
|
||||
t.Helper()
|
||||
s, evs, err := deal(bet, tier, d, rake)
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "deal" {
|
||||
t.Fatalf("deal events = %+v, want one deal", evs)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func apply(t *testing.T, s State, m Move) (State, []Event) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyMove(%+v): %v", m, err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func refuses(t *testing.T, s State, m Move, want error) {
|
||||
t.Helper()
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err == nil {
|
||||
t.Fatalf("ApplyMove(%+v) was allowed, want %v", m, want)
|
||||
}
|
||||
if want != nil && err != want {
|
||||
t.Fatalf("ApplyMove(%+v) = %v, want %v", m, err, want)
|
||||
}
|
||||
if evs != nil {
|
||||
t.Errorf("an illegal move emitted events: %+v", evs)
|
||||
}
|
||||
// The board an illegal move hands back must be the one it was given. This is
|
||||
// the whole contract of the reducer, and it's cheap to check by value.
|
||||
if !sameBoard(next, s) {
|
||||
t.Errorf("an illegal move changed the board")
|
||||
}
|
||||
}
|
||||
|
||||
func sameBoard(a, b State) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
return string(x) == string(y)
|
||||
}
|
||||
|
||||
// ---- the deal --------------------------------------------------------------
|
||||
|
||||
func TestDealLaysOutTheBoard(t *testing.T) {
|
||||
s := mustDeal(t, 520, vegas(), ordered())
|
||||
|
||||
seen := 0
|
||||
for i := 0; i < Piles; i++ {
|
||||
p := s.Table[i]
|
||||
if len(p.Up) != 1 {
|
||||
t.Errorf("column %d has %d face up, want 1", i, len(p.Up))
|
||||
}
|
||||
if len(p.Down) != i {
|
||||
t.Errorf("column %d has %d face down, want %d", i, len(p.Down), i)
|
||||
}
|
||||
seen += len(p.Up) + len(p.Down)
|
||||
}
|
||||
if seen != 28 {
|
||||
t.Errorf("tableau holds %d cards, want 28", seen)
|
||||
}
|
||||
if len(s.Stock) != 24 {
|
||||
t.Errorf("stock is %d, want 24", len(s.Stock))
|
||||
}
|
||||
if s.Home() != 0 || s.Pays() != 0 {
|
||||
t.Errorf("a fresh board is worth %d from %d home, want nothing", s.Pays(), s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealRefusesABadStake(t *testing.T) {
|
||||
if _, _, err := deal(0, vegas(), ordered(), rake); err != ErrBadBet {
|
||||
t.Fatalf("deal(0) = %v, want ErrBadBet", err)
|
||||
}
|
||||
if _, _, err := New(-5, vegas(), rake, cards.NewRNG(1, 2)); err != ErrBadBet {
|
||||
t.Fatalf("New(-5) = %v, want ErrBadBet", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the stock -------------------------------------------------------------
|
||||
|
||||
func TestDrawTurnsTheTiersCount(t *testing.T) {
|
||||
for _, tier := range []Tier{patient(), vegas()} {
|
||||
s := mustDeal(t, 100, tier, ordered())
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(next.Waste) != tier.Draw {
|
||||
t.Errorf("%s: waste is %d after one draw, want %d", tier.Slug, len(next.Waste), tier.Draw)
|
||||
}
|
||||
if len(next.Stock) != 24-tier.Draw {
|
||||
t.Errorf("%s: stock is %d, want %d", tier.Slug, len(next.Stock), 24-tier.Draw)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "draw" || len(evs[0].Cards) != tier.Draw {
|
||||
t.Errorf("%s: draw events = %+v", tier.Slug, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The last pull off a short stock turns over what's left rather than refusing.
|
||||
func TestDrawTakesWhatIsLeft(t *testing.T) {
|
||||
s := mustDeal(t, 100, vegas(), ordered()) // 24 in the stock, drawing 3
|
||||
for i := 0; i < 7; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"}) // 21 drawn, 3 left
|
||||
}
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
if len(s.Stock) != 0 || len(s.Waste) != 24 {
|
||||
t.Fatalf("stock %d waste %d, want 0 and 24", len(s.Stock), len(s.Waste))
|
||||
}
|
||||
refuses(t, drained(t, s), Move{Kind: "draw"}, ErrNoDraw)
|
||||
}
|
||||
|
||||
// drained empties the waste too, so there is genuinely nothing to turn over.
|
||||
func drained(t *testing.T, s State) State {
|
||||
t.Helper()
|
||||
s = s.clone()
|
||||
s.Waste = nil
|
||||
s.Stock = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// The waste goes back under the stock in the order it came out — a recycle is a
|
||||
// pile being turned over, not reshuffled. If this ever reshuffled, the seed in
|
||||
// the audit log would stop replaying the game.
|
||||
func TestRecycleTurnsTheWasteOverInOrder(t *testing.T) {
|
||||
s := mustDeal(t, 100, patient(), ordered())
|
||||
want := append(cards.Deck(nil), s.Stock...)
|
||||
|
||||
for i := 0; i < 24; i++ {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, evs := apply(t, s, Move{Kind: "draw"})
|
||||
if len(evs) != 1 || evs[0].Kind != "recycle" {
|
||||
t.Fatalf("events = %+v, want a recycle", evs)
|
||||
}
|
||||
if len(next.Waste) != 0 {
|
||||
t.Errorf("waste is %d after a recycle, want empty", len(next.Waste))
|
||||
}
|
||||
for i := range want {
|
||||
if next.Stock[i] != want[i] {
|
||||
t.Fatalf("stock[%d] = %v after recycle, want %v — the pile was reshuffled",
|
||||
i, next.Stock[i], want[i])
|
||||
}
|
||||
}
|
||||
if next.Recycles != 1 {
|
||||
t.Errorf("recycles = %d, want 1", next.Recycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Passes is how many times you may go *through* the stock, so it is one more
|
||||
// than the number of times you may turn it back over.
|
||||
func TestPassesRunOut(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier Tier
|
||||
recycles int // how many turn-overs the tier should allow
|
||||
}{
|
||||
{cut(), 0}, // one pass: you never get to turn it back over
|
||||
{vegas(), 2}, // three passes: two turn-overs
|
||||
{patient(), -1}, // unlimited
|
||||
}
|
||||
for _, tc := range tests {
|
||||
s := mustDeal(t, 100, tc.tier, ordered())
|
||||
if got := s.PassesLeft(); tc.recycles < 0 && got != -1 {
|
||||
t.Errorf("%s: PassesLeft = %d, want -1 (unlimited)", tc.tier.Slug, got)
|
||||
}
|
||||
allowed := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
// Empty the stock, then try to turn it over.
|
||||
for len(s.Stock) > 0 {
|
||||
s, _ = apply(t, s, Move{Kind: "draw"})
|
||||
}
|
||||
next, _, err := ApplyMove(s, Move{Kind: "draw"})
|
||||
if err == ErrNoPasses {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.tier.Slug, err)
|
||||
}
|
||||
s = next
|
||||
allowed++
|
||||
}
|
||||
if tc.recycles < 0 {
|
||||
if allowed != 5 {
|
||||
t.Errorf("%s: only %d recycles allowed, want unlimited", tc.tier.Slug, allowed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if allowed != tc.recycles {
|
||||
t.Errorf("%s: %d recycles allowed, want %d", tc.tier.Slug, allowed, tc.recycles)
|
||||
}
|
||||
if s.PassesLeft() != 1 {
|
||||
t.Errorf("%s: PassesLeft = %d on the last pass, want 1", tc.tier.Slug, s.PassesLeft())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules -------------------------------------------------------------
|
||||
|
||||
// board builds a State directly, so a rule can be tested against the position
|
||||
// that exercises it rather than against whatever a shuffle happened to deal.
|
||||
func board(tier Tier, bet int64) State {
|
||||
return State{Tier: tier, Bet: bet, RakePct: rake, Phase: PhasePlaying}
|
||||
}
|
||||
|
||||
func TestTableauTakesDescendingAlternatingColour(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)} // black 8
|
||||
s.Table[1].Up = []cards.Card{card(7, cards.Hearts)} // red 7 — goes on the 8
|
||||
s.Table[2].Up = []cards.Card{card(7, cards.Clubs)} // black 7 — does not
|
||||
s.Table[3].Up = []cards.Card{card(6, cards.Hearts)} // red 6 — wrong rank for the 8
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 2 || next.Table[0].Up[1] != card(7, cards.Hearts) {
|
||||
t.Fatalf("the red seven didn't land on the black eight: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[1].Up) != 0 {
|
||||
t.Errorf("the seven is still in its old column")
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "move" {
|
||||
t.Errorf("events = %+v, want one move", evs)
|
||||
}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo) // same colour
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrWontGo) // two below
|
||||
}
|
||||
|
||||
func TestOnlyAKingTakesAnEmptyColumn(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
// t0 is empty and has nothing under it.
|
||||
s.Table[1].Up = []cards.Card{card(cards.King, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Queen, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "t0"}, ErrWontGo)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t1", To: "t0"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0].Rank != cards.King {
|
||||
t.Fatalf("the king didn't take the empty column: %+v", next.Table[0].Up)
|
||||
}
|
||||
}
|
||||
|
||||
// A run comes off the tableau as a block, and only if it is a run.
|
||||
func TestLiftingARun(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts), // red
|
||||
card(8, cards.Spades), // black
|
||||
card(7, cards.Diamonds), // red
|
||||
}
|
||||
s.Table[1].Up = []cards.Card{card(10, cards.Clubs)} // black 10 takes the red 9
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "t0", To: "t1", Count: 3})
|
||||
if len(next.Table[1].Up) != 4 || len(next.Table[0].Up) != 0 {
|
||||
t.Fatalf("the run didn't move as a block: t0=%v t1=%v", next.Table[0].Up, next.Table[1].Up)
|
||||
}
|
||||
|
||||
// Not a run: same colour in the middle of it.
|
||||
bad := board(vegas(), 520)
|
||||
bad.Table[0].Up = []cards.Card{
|
||||
card(9, cards.Hearts),
|
||||
card(8, cards.Diamonds), // red on red
|
||||
}
|
||||
bad.Table[1].Up = []cards.Card{card(10, cards.Clubs)}
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 2}, ErrNotASequence)
|
||||
|
||||
// And you can't lift more cards than the column has.
|
||||
refuses(t, bad, Move{Kind: "move", From: "t0", To: "t1", Count: 9}, ErrNotASequence)
|
||||
}
|
||||
|
||||
// Taking the last face-up card off a column turns the next one over. This is the
|
||||
// only thing in the game that reveals a card, which is the point of the test.
|
||||
func TestTakingTheLastCardFlipsTheNextOne(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
hidden := card(cards.Queen, cards.Clubs)
|
||||
s.Table[0].Down = []cards.Card{card(2, cards.Spades), hidden}
|
||||
s.Table[0].Up = []cards.Card{card(7, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "move", From: "t0", To: "t1"})
|
||||
if len(next.Table[0].Up) != 1 || next.Table[0].Up[0] != hidden {
|
||||
t.Fatalf("the hidden card didn't turn over: %+v", next.Table[0].Up)
|
||||
}
|
||||
if len(next.Table[0].Down) != 1 {
|
||||
t.Errorf("face-down stack is %d, want 1", len(next.Table[0].Down))
|
||||
}
|
||||
if len(evs) != 2 || evs[1].Kind != "flip" || evs[1].Cards[0] != hidden {
|
||||
t.Fatalf("events = %+v, want a move then a flip carrying the card", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoundationsBuildUpBySuitFromTheAce(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(2, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(3, cards.Hearts)}
|
||||
|
||||
// A two can't start a foundation.
|
||||
refuses(t, s, Move{Kind: "home", From: "t1"}, ErrWontGo)
|
||||
|
||||
s, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if len(s.Found[cards.Hearts]) != 1 {
|
||||
t.Fatalf("the ace didn't go home: %+v", s.Found)
|
||||
}
|
||||
if evs[0].Kind != "home" || evs[0].To != "f"+strconv.Itoa(int(cards.Hearts)) {
|
||||
t.Fatalf("event = %+v, want a home to the hearts pile", evs[0])
|
||||
}
|
||||
if evs[0].Home != 1 {
|
||||
t.Errorf("event carries Home=%d, want 1", evs[0].Home)
|
||||
}
|
||||
|
||||
// The three can't jump the two, and the two of spades can't go on hearts.
|
||||
refuses(t, s, Move{Kind: "home", From: "t3"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "t2", To: "f" + strconv.Itoa(int(cards.Hearts))}, ErrWontGo)
|
||||
|
||||
s, _ = apply(t, s, Move{Kind: "home", From: "t1"})
|
||||
if s.Home() != 2 {
|
||||
t.Errorf("Home = %d, want 2", s.Home())
|
||||
}
|
||||
}
|
||||
|
||||
// A card can come back off a foundation — a real rule, and one that matters when
|
||||
// you need a low card to move a column. The payout follows it back down, because
|
||||
// the payout reads the board rather than counting events.
|
||||
func TestACardComesBackOffAFoundation(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(3, cards.Spades)}
|
||||
|
||||
before := s.Pays()
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "f" + strconv.Itoa(int(cards.Hearts)), To: "t0"})
|
||||
if len(next.Found[cards.Hearts]) != 1 || len(next.Table[0].Up) != 2 {
|
||||
t.Fatalf("the two didn't come back down: found=%v t0=%v", next.Found[cards.Hearts], next.Table[0].Up)
|
||||
}
|
||||
if next.Home() != 1 {
|
||||
t.Errorf("Home = %d after taking a card back, want 1", next.Home())
|
||||
}
|
||||
if next.Pays() >= before {
|
||||
t.Errorf("Pays = %d after taking a card back, want less than %d", next.Pays(), before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWasteGivesUpItsTopCardOnly(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Waste = []cards.Card{card(5, cards.Spades), card(7, cards.Hearts)}
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
// The 5 is under the 7 and is not available, however much you'd like it.
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0", Count: 2}, ErrNotASequence)
|
||||
|
||||
next, _ := apply(t, s, Move{Kind: "move", From: "waste", To: "t0"})
|
||||
if len(next.Waste) != 1 || next.Waste[0] != card(5, cards.Spades) {
|
||||
t.Fatalf("the wrong card left the waste: %+v", next.Waste)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPilesAndNonsensePiles(t *testing.T) {
|
||||
s := board(vegas(), 520)
|
||||
s.Table[0].Up = []cards.Card{card(8, cards.Spades)}
|
||||
|
||||
refuses(t, s, Move{Kind: "move", From: "waste", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t3", To: "t0"}, ErrEmptyPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t9", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "move", From: "t0", To: "t9"}, ErrWontGo)
|
||||
refuses(t, s, Move{Kind: "move", From: "banana", To: "t0"}, ErrBadPile)
|
||||
refuses(t, s, Move{Kind: "sing"}, ErrUnknownMove)
|
||||
}
|
||||
|
||||
// ---- auto ------------------------------------------------------------------
|
||||
|
||||
func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
// Two aces and the hearts two, sitting on top of three columns.
|
||||
s.Table[0].Up = []cards.Card{card(cards.Ace, cards.Hearts)}
|
||||
s.Table[1].Up = []cards.Card{card(2, cards.Hearts)}
|
||||
s.Table[2].Up = []cards.Card{card(cards.Ace, cards.Spades)}
|
||||
s.Table[3].Up = []cards.Card{card(9, cards.Clubs)} // goes nowhere
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "auto"})
|
||||
if next.Home() != 3 {
|
||||
t.Fatalf("Home = %d after auto, want 3 (two aces and the two)", next.Home())
|
||||
}
|
||||
if len(next.Table[3].Up) != 1 {
|
||||
t.Errorf("the nine went somewhere it couldn't go")
|
||||
}
|
||||
homes := 0
|
||||
for _, e := range evs {
|
||||
if e.Kind == "home" {
|
||||
homes++
|
||||
}
|
||||
}
|
||||
if homes != 3 {
|
||||
t.Errorf("auto emitted %d home events, want 3 — the table has to animate each one", homes)
|
||||
}
|
||||
|
||||
// Nothing left to do: the button says so rather than doing nothing quietly.
|
||||
if next.CanAuto() {
|
||||
t.Errorf("CanAuto is true with only a nine on the board")
|
||||
}
|
||||
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// The number the felt quotes while you play and the number settle() lands on are
|
||||
// the same function. Hangman had these as two sums once and the table advertised
|
||||
// a payout the house didn't honour; this asserts they can't drift here.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for home := 0; home <= FullDeck; home++ {
|
||||
s.Found = [Foundations][]cards.Card{}
|
||||
left := home
|
||||
for suit := 0; suit < Foundations && left > 0; suit++ {
|
||||
n := left
|
||||
if n > 13 {
|
||||
n = 13
|
||||
}
|
||||
for r := 1; r <= n; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if s.Home() != home {
|
||||
t.Fatalf("built a board with %d home, wanted %d", s.Home(), home)
|
||||
}
|
||||
|
||||
quoted := s.Pays()
|
||||
var evs []Event
|
||||
done := s.clone()
|
||||
done.settle(OutcomeCashed, &evs)
|
||||
if done.Payout != quoted {
|
||||
t.Fatalf("%d home: the felt quoted %d and settle paid %d", home, quoted, done.Payout)
|
||||
}
|
||||
if done.Payout+done.Rake != done.Earned() {
|
||||
t.Fatalf("%d home: payout %d + rake %d != earned %d",
|
||||
home, done.Payout, done.Rake, done.Earned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFullBoardPaysTheTiersMultiple(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
for r := 1; r <= 13; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
// Gross is the multiple exactly — computed from the total, not summed 52
|
||||
// times, so it doesn't bleed a rounding per card.
|
||||
want := int64(float64(s.Bet) * tier.Base)
|
||||
if s.Earned() != want {
|
||||
t.Errorf("%s: a cleared board earns %d, want %d", tier.Slug, s.Earned(), want)
|
||||
}
|
||||
// And the rake comes out of the winnings, never the stake.
|
||||
profit := want - s.Bet
|
||||
if s.Pays() != want-int64(float64(profit)*rake) {
|
||||
t.Errorf("%s: pays %d, want %d less %v%% of the profit", tier.Slug, s.Pays(), want, rake*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty board owes nothing, and is not charged a fee for owing nothing.
|
||||
func TestNothingHomePaysNothing(t *testing.T) {
|
||||
s := board(cut(), 500)
|
||||
if s.Pays() != 0 || s.rakeNow() != 0 {
|
||||
t.Fatalf("an empty board pays %d and rakes %d, want nothing either way", s.Pays(), s.rakeNow())
|
||||
}
|
||||
var evs []Event
|
||||
s.settle(OutcomeCashed, &evs)
|
||||
if s.Payout != 0 || s.Net() != -500 {
|
||||
t.Errorf("payout %d net %d, want 0 and -500", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// Below break-even the player is down but is not raked: there is no profit to
|
||||
// take a cut of.
|
||||
func TestNoRakeBelowTheStake(t *testing.T) {
|
||||
tier := vegas()
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven()-1; i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() > s.Bet {
|
||||
t.Fatalf("break-even is meant to be the first card that gets you square, but %d earns %d on a %d stake",
|
||||
s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
if s.rakeNow() != 0 {
|
||||
t.Errorf("raked %d off a losing board", s.rakeNow())
|
||||
}
|
||||
if s.Pays() != s.Earned() {
|
||||
t.Errorf("pays %d, want the full %d — nothing to rake", s.Pays(), s.Earned())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakEvenIsTheCardThatGetsYouSquare(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := board(tier, 5200)
|
||||
for i := 0; i < tier.BreakEven(); i++ {
|
||||
suit, r := i/13, i%13+1
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
if s.Earned() < s.Bet {
|
||||
t.Errorf("%s: %d cards home earns %d on a %d stake — break-even is quoted too low",
|
||||
tier.Slug, s.Home(), s.Earned(), s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settling --------------------------------------------------------------
|
||||
|
||||
func TestConcedeCashesTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 5200)
|
||||
s.Found[cards.Hearts] = []cards.Card{card(cards.Ace, cards.Hearts), card(2, cards.Hearts)}
|
||||
want := s.Pays()
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "concede"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCashed {
|
||||
t.Fatalf("phase %q outcome %q, want done/cashed", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != want {
|
||||
t.Errorf("cashed for %d, want the %d the board was quoting", next.Payout, want)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("no settle event: %+v", evs)
|
||||
}
|
||||
refuses(t, next, Move{Kind: "draw"}, ErrGameOver)
|
||||
}
|
||||
|
||||
// The last card home ends the game on its own — the player doesn't have to tell
|
||||
// the table they've won.
|
||||
func TestTheLastCardHomeClearsTheBoard(t *testing.T) {
|
||||
s := board(vegas(), 1000)
|
||||
for suit := 0; suit < Foundations; suit++ {
|
||||
top := 13
|
||||
if suit == int(cards.Clubs) {
|
||||
top = 12 // the king of clubs is the one card still out
|
||||
}
|
||||
for r := 1; r <= top; r++ {
|
||||
s.Found[suit] = append(s.Found[suit], card(cards.Rank(r), cards.Suit(suit)))
|
||||
}
|
||||
}
|
||||
s.Table[0].Up = []cards.Card{card(cards.King, cards.Clubs)}
|
||||
|
||||
next, evs := apply(t, s, Move{Kind: "home", From: "t0"})
|
||||
if next.Phase != PhaseDone || next.Outcome != OutcomeCleared {
|
||||
t.Fatalf("phase %q outcome %q, want done/cleared", next.Phase, next.Outcome)
|
||||
}
|
||||
if next.Payout != int64(float64(1000)*vegas().Base)-int64(float64(int64(float64(1000)*vegas().Base)-1000)*rake) {
|
||||
t.Errorf("a cleared board paid %d", next.Payout)
|
||||
}
|
||||
if evs[len(evs)-1].Kind != "settle" {
|
||||
t.Errorf("the winning card didn't settle the game: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the shape of the thing ------------------------------------------------
|
||||
|
||||
// A game survives a redeploy: the whole state, shoe and face-down cards and all,
|
||||
// goes through JSON and comes back the same board.
|
||||
func TestAGameSurvivesJSON(t *testing.T) {
|
||||
s, _, err := New(500, cut(), rake, cards.NewRNG(7, 11))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
s, _, _ = ApplyMove(s, Move{Kind: "draw"})
|
||||
}
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(s, back) {
|
||||
t.Fatal("the board didn't come back the same")
|
||||
}
|
||||
}
|
||||
|
||||
// The same seed deals the same board. This is what lets a disputed game be dealt
|
||||
// again exactly as it fell, and it is why the RNG is threaded rather than global.
|
||||
func TestASeedDealsTheSameBoard(t *testing.T) {
|
||||
a, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := New(100, vegas(), rake, cards.NewRNG(42, 99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameBoard(a, b) {
|
||||
t.Fatal("the same seed dealt two different boards")
|
||||
}
|
||||
|
||||
c, _, _ := New(100, vegas(), rake, cards.NewRNG(43, 99))
|
||||
if sameBoard(a, c) {
|
||||
t.Fatal("two seeds dealt the same board")
|
||||
}
|
||||
}
|
||||
|
||||
// Every card is on the board exactly once, whatever you do to it. A move that
|
||||
// duplicated a card would be a move that printed money.
|
||||
func TestNoCardIsEverLostOrDuplicated(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 5))
|
||||
s, _, err := New(1000, patient(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countDeck(t, s, "the deal")
|
||||
|
||||
// Play a long random game: whatever the fuzzer stumbles into, the deck holds.
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
m := randomMove(rng)
|
||||
next, _, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
continue // an illegal move is a fine thing for a fuzzer to find
|
||||
}
|
||||
s = next
|
||||
countDeck(t, s, "after "+m.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func randomMove(rng *rand.Rand) Move {
|
||||
pile := func() string {
|
||||
switch rng.IntN(3) {
|
||||
case 0:
|
||||
return "waste"
|
||||
case 1:
|
||||
return "t" + strconv.Itoa(rng.IntN(Piles))
|
||||
default:
|
||||
return "f" + strconv.Itoa(rng.IntN(Foundations))
|
||||
}
|
||||
}
|
||||
switch rng.IntN(10) {
|
||||
case 0, 1, 2, 3:
|
||||
return Move{Kind: "draw"}
|
||||
case 4:
|
||||
return Move{Kind: "home", From: pile()}
|
||||
case 5:
|
||||
return Move{Kind: "auto"}
|
||||
default:
|
||||
return Move{Kind: "move", From: pile(), To: pile(), Count: 1 + rng.IntN(4)}
|
||||
}
|
||||
}
|
||||
|
||||
func countDeck(t *testing.T, s State, when string) {
|
||||
t.Helper()
|
||||
seen := map[cards.Card]int{}
|
||||
add := func(cs []cards.Card) {
|
||||
for _, c := range cs {
|
||||
seen[c]++
|
||||
}
|
||||
}
|
||||
add(s.Stock)
|
||||
add(s.Waste)
|
||||
for _, p := range s.Table {
|
||||
add(p.Down)
|
||||
add(p.Up)
|
||||
}
|
||||
for _, f := range s.Found {
|
||||
add(f)
|
||||
}
|
||||
if len(seen) != FullDeck {
|
||||
t.Fatalf("%s: %d distinct cards on the board, want 52", when, len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 1 {
|
||||
t.Fatalf("%s: %v appears %d times", when, c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The face-up run in every tableau column is always a legal run, and a column
|
||||
// with cards face-up never has an unturned card left under it. Both are things
|
||||
// the *rules* keep true, so a fuzzer that breaks them has found a real bug.
|
||||
func TestTheBoardStaysWellFormed(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(11, 13))
|
||||
s, _, err := New(1000, vegas(), rake, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4000 && s.Phase == PhasePlaying; i++ {
|
||||
next, _, err := ApplyMove(s, randomMove(rng))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s = next
|
||||
for j, p := range s.Table {
|
||||
if !isRun(p.Up) {
|
||||
t.Fatalf("column %d holds a run that isn't one: %v", j, p.Up)
|
||||
}
|
||||
if len(p.Up) == 0 && len(p.Down) > 0 {
|
||||
t.Fatalf("column %d has %d cards face down and nothing turned over", j, len(p.Down))
|
||||
}
|
||||
}
|
||||
for suit, f := range s.Found {
|
||||
for r, c := range f {
|
||||
if int(c.Suit) != suit || int(c.Rank) != r+1 {
|
||||
t.Fatalf("foundation %d holds %v at position %d", suit, c, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
375
internal/games/trivia/trivia.go
Normal file
375
internal/games/trivia/trivia.go
Normal file
@@ -0,0 +1,375 @@
|
||||
// Package trivia is a pure trivia-ladder engine, played for chips.
|
||||
//
|
||||
// Same seam as blackjack and hangman: ApplyMove(state, move, now) (state,
|
||||
// events, error), where an error means the move was illegal and nothing else.
|
||||
// The one difference is that clock: trivia is the only game in the room where
|
||||
// *when* you move changes what it pays, and a pure reducer cannot own a timer.
|
||||
// So the time is an argument. The engine stays a value in, value out, and the
|
||||
// only thing that knows what o'clock it is remains the caller.
|
||||
//
|
||||
// The shape is a ladder. You stake once, and then answer a run of questions:
|
||||
// every right answer multiplies what the stake is worth, a wrong one loses the
|
||||
// lot, and you may walk with what you've built at any point after the first.
|
||||
// It is the oldest quiz-show bet there is — the tension is entirely in whether
|
||||
// you take the money.
|
||||
//
|
||||
// The reason for the clock is less pretty: trivia answers are googlable, and a
|
||||
// game that paid the same for a slow right answer as a fast one would be a game
|
||||
// about typing into another tab. So the multiple a question is worth decays
|
||||
// from Fast to Buzzer across the tier's time limit, and running out of time
|
||||
// loses exactly as much as being wrong. The countdown in the browser is
|
||||
// decoration; this is the clock that counts.
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("trivia: the game is already over")
|
||||
ErrUnknownMove = errors.New("trivia: unknown move")
|
||||
ErrBadBet = errors.New("trivia: bet must be positive")
|
||||
ErrUnknownTier = errors.New("trivia: no such tier")
|
||||
ErrShortLadder = errors.New("trivia: not enough questions to build a ladder")
|
||||
ErrNothingBanked = errors.New("trivia: answer one before you walk")
|
||||
)
|
||||
|
||||
// Rungs is how long the ladder is. Clearing it is a win in itself: the run ends
|
||||
// and banks, because a ladder with no top is just a slot machine you can't stop
|
||||
// playing, and eventually every player loses everything to one bad question.
|
||||
const Rungs = 12
|
||||
|
||||
// Tier is a difficulty, chosen before the bet. It sets three things that move
|
||||
// together: how hard the questions are, how long you get, and what a right
|
||||
// answer is worth. Hard questions pay more and give you less time to look them
|
||||
// up, which is the whole bargain.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Difficulty string `json:"difficulty"` // what OpenTDB calls it: easy | medium | hard
|
||||
Fast float64 `json:"fast"` // what a right answer multiplies by, answered instantly
|
||||
Buzzer float64 `json:"buzzer"` // ...and what it's worth answered on the last tick
|
||||
Limit int `json:"limit"` // seconds on the clock, per question
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "easy", Name: "Easy", Difficulty: "easy", Fast: 1.30, Buzzer: 1.10, Limit: 20,
|
||||
Blurb: "Things you know. The clock is the only thing in your way."},
|
||||
{Slug: "medium", Name: "Medium", Difficulty: "medium", Fast: 1.55, Buzzer: 1.20, Limit: 18,
|
||||
Blurb: "Things you nearly know."},
|
||||
{Slug: "hard", Name: "Hard", Difficulty: "hard", Fast: 1.90, Buzzer: 1.30, Limit: 15,
|
||||
Blurb: "Things you don't. Fifteen seconds is not enough to find out."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier 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
|
||||
}
|
||||
|
||||
// Step is what a right answer multiplies the running total by, given how long
|
||||
// it took. Fast at nought seconds, Buzzer at the limit, straight line between.
|
||||
//
|
||||
// Answering at the buzzer still pays *something* — the decay is a reason to be
|
||||
// quick, not a punishment for thinking. The punishment for thinking too long is
|
||||
// the timeout, and that one takes everything.
|
||||
func (t Tier) Step(elapsed time.Duration) float64 {
|
||||
limit := t.Clock()
|
||||
switch {
|
||||
case elapsed <= 0:
|
||||
return t.Fast
|
||||
case elapsed >= limit:
|
||||
return t.Buzzer
|
||||
}
|
||||
speed := 1 - float64(elapsed)/float64(limit) // 1 answering instantly, 0 at the buzzer
|
||||
return t.Buzzer + (t.Fast-t.Buzzer)*speed
|
||||
}
|
||||
|
||||
// Clock is the tier's time limit as a duration.
|
||||
func (t Tier) Clock() time.Duration { return time.Duration(t.Limit) * time.Second }
|
||||
|
||||
// Question is one rung. It carries its own correct index, which is exactly why
|
||||
// a State never crosses the wire — the browser is sent the answers and not
|
||||
// which of them is right.
|
||||
type Question struct {
|
||||
Category string `json:"category"`
|
||||
Text string `json:"text"`
|
||||
Answers []string `json:"answers"` // already shuffled: the right one is not always first
|
||||
Correct int `json:"correct"` // index into Answers
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlaying Phase = "playing"
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeWalked Outcome = "walked" // took the money
|
||||
OutcomeCleared Outcome = "cleared" // answered all twelve
|
||||
OutcomeWrong Outcome = "wrong" // picked the wrong one
|
||||
OutcomeTimeout Outcome = "timeout" // ran out of clock
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeWalked || o == OutcomeCleared }
|
||||
|
||||
// State is one game. The ladder — every question, and every right answer — is
|
||||
// in here, which is why this value stays on the server. The browser gets a view
|
||||
// of the current rung and nothing about the ones ahead of it.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Ladder []Question `json:"ladder"` // the whole run, drawn up front
|
||||
Rung int `json:"rung"` // how many answered right; also the index of the live question
|
||||
|
||||
// AskedAt is when the current question was *put to the player*, by the
|
||||
// server's clock. It is the only clock in the game. A reload does not reset
|
||||
// it: you cannot stop time by refreshing.
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
|
||||
Multiple float64 `json:"multiple"` // 1.0 at the start; the product of every step earned
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // "ask" | "right" | "wrong" | "timeout" | "settle"
|
||||
Choice int `json:"choice"` // what the player picked (-1 when they didn't)
|
||||
Correct int `json:"correct"` // which one was right — sent only once it's decided
|
||||
Step float64 `json:"step,omitempty"` // what this answer multiplied by
|
||||
Multiple float64 `json:"multiple,omitempty"` // the running total, after
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Move is a player action: pick an answer, or take the money.
|
||||
type Move struct {
|
||||
Choice int `json:"choice"` // index into the live question's answers
|
||||
Walk bool `json:"walk"`
|
||||
}
|
||||
|
||||
// New starts a game on a ladder of questions the caller has already drawn. The
|
||||
// engine does not reach for a database any more than blackjack reaches for a
|
||||
// deck: the questions arrive as a value, so a game is reproducible and a test
|
||||
// can pin every rung.
|
||||
//
|
||||
// The answers are shuffled here, with the caller's seeded rng, because a bank
|
||||
// that always stores the right answer first would otherwise be a game about
|
||||
// clicking first.
|
||||
func New(bet int64, t Tier, rakePct float64, qs []Question, now time.Time, rng *rand.Rand) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if len(qs) < Rungs {
|
||||
return State{}, nil, ErrShortLadder
|
||||
}
|
||||
ladder := make([]Question, Rungs)
|
||||
for i := range ladder {
|
||||
ladder[i] = shuffleAnswers(qs[i], rng)
|
||||
}
|
||||
s := State{
|
||||
Tier: t, Ladder: ladder, RakePct: rakePct,
|
||||
Multiple: 1,
|
||||
AskedAt: now,
|
||||
Bet: bet, Phase: PhasePlaying,
|
||||
}
|
||||
return s, []Event{{Kind: "ask", Choice: -1, Correct: -1}}, nil
|
||||
}
|
||||
|
||||
// shuffleAnswers moves the right answer somewhere the player can't guess from
|
||||
// position, and keeps track of where it went.
|
||||
func shuffleAnswers(q Question, rng *rand.Rand) Question {
|
||||
answers := append([]string(nil), q.Answers...)
|
||||
correct := q.Answers[q.Correct]
|
||||
rng.Shuffle(len(answers), func(i, j int) { answers[i], answers[j] = answers[j], answers[i] })
|
||||
out := q
|
||||
out.Answers = answers
|
||||
for i, a := range answers {
|
||||
if a == correct {
|
||||
out.Correct = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Live is the question the player is looking at.
|
||||
func (s State) Live() Question {
|
||||
if s.Rung < 0 || s.Rung >= len(s.Ladder) {
|
||||
return Question{}
|
||||
}
|
||||
return s.Ladder[s.Rung]
|
||||
}
|
||||
|
||||
// Left is how much clock the live question has, at the given moment. It goes to
|
||||
// the browser so its countdown starts where the server's does — but the browser
|
||||
// is never asked what it says.
|
||||
func (s State) Left(now time.Time) time.Duration {
|
||||
d := s.Tier.Clock() - now.Sub(s.AskedAt)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. now is the server's clock, and the only one that
|
||||
// counts toward the answer.
|
||||
func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
s = s.clone()
|
||||
|
||||
q := s.Live()
|
||||
if len(q.Answers) == 0 {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
elapsed := now.Sub(s.AskedAt)
|
||||
|
||||
// Out of time. This is a loss, and it has to be — a timeout that merely cost
|
||||
// you the speed bonus would make "leave it open in another tab and go and
|
||||
// look it up" the strongest way to play.
|
||||
//
|
||||
// It is checked before *everything*, walking included. A dead clock that you
|
||||
// could still walk away from would be no clock at all: you would sit on every
|
||||
// question for as long as you liked, answer the ones you found and walk off
|
||||
// the ones you didn't, and never once lose the ladder. The timeout has to be
|
||||
// the first thing that happens to a move, or it is not a deadline.
|
||||
if elapsed > s.Tier.Clock() {
|
||||
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||
s.settle(OutcomeTimeout, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Walk {
|
||||
// You cannot walk off a rung you haven't climbed. If you could, seeing the
|
||||
// first question and walking away would be a free look: stake, peek, walk,
|
||||
// stake again, and keep reshuffling until the question is one you know.
|
||||
// The first question is therefore the price of sitting down.
|
||||
if s.Rung == 0 {
|
||||
return s, nil, ErrNothingBanked
|
||||
}
|
||||
evs := []Event{}
|
||||
s.settle(OutcomeWalked, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
|
||||
if m.Choice != q.Correct {
|
||||
evs := []Event{{Kind: "wrong", Choice: m.Choice, Correct: q.Correct}}
|
||||
s.settle(OutcomeWrong, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Right, and quick enough to be worth something.
|
||||
step := s.Tier.Step(elapsed)
|
||||
s.Multiple *= step
|
||||
s.Rung++
|
||||
evs := []Event{{
|
||||
Kind: "right", Choice: m.Choice, Correct: q.Correct,
|
||||
Step: step, Multiple: s.Multiple,
|
||||
}}
|
||||
|
||||
if s.Rung >= Rungs {
|
||||
s.settle(OutcomeCleared, &evs)
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// The next question goes up, and its clock starts now.
|
||||
s.AskedAt = now
|
||||
evs = append(evs, Event{Kind: "ask", Choice: -1, Correct: -1})
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Pays is what banking *right now* would put back on the player's stack: the
|
||||
// stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists for the same reason hangman's does. The felt quotes this number
|
||||
// while the game is still running — it is the "take the money" button's label —
|
||||
// and settle() calls it rather than doing the sum a second time, so the table
|
||||
// can never advertise a payout the house doesn't hand over.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total < s.Bet {
|
||||
total = s.Bet // banking never hands back less than the stake
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake > 0 {
|
||||
profit -= rake
|
||||
}
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||
// banked at this moment. Never taken from the stake, so a player who walks
|
||||
// having answered nothing — which they can't — and one who loses, pay nothing.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Multiple))
|
||||
if total <= s.Bet {
|
||||
return 0
|
||||
}
|
||||
rake := int64(math.Floor(float64(total-s.Bet) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// settle decides the payout. Same rule as every other table in the room: the
|
||||
// rake comes out of winnings, never out of the stake, and a loss is never
|
||||
// charged a fee.
|
||||
func (s *State) settle(o Outcome, evs *[]Event) {
|
||||
s.Outcome = o
|
||||
s.Phase = PhaseDone
|
||||
|
||||
if o.Won() {
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Payout = 0
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "settle", Choice: -1, Correct: -1, Text: string(o)})
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// clone deep-copies the ladder, so a derived state shares no backing array with
|
||||
// the one it came from and a game can be replayed freely.
|
||||
func (s State) clone() State {
|
||||
s.Ladder = append([]Question(nil), s.Ladder...)
|
||||
return s
|
||||
}
|
||||
352
internal/games/trivia/trivia_test.go
Normal file
352
internal/games/trivia/trivia_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package trivia
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rng() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||
|
||||
var epoch = time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// bank builds n questions whose right answer is always "right", so a test can
|
||||
// find it after the shuffle without caring where it landed.
|
||||
func bank(n int) []Question {
|
||||
qs := make([]Question, n)
|
||||
for i := range qs {
|
||||
qs[i] = Question{
|
||||
Category: "General",
|
||||
Text: "question?",
|
||||
Answers: []string{"right", "wrong1", "wrong2", "wrong3"},
|
||||
Correct: 0,
|
||||
}
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
func tier(slug string) Tier {
|
||||
t, err := TierBySlug(slug)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func newGame(t *testing.T, bet int64, slug string) State {
|
||||
t.Helper()
|
||||
s, evs, err := New(bet, tier(slug), 0.05, bank(Rungs), epoch, rng())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != "ask" {
|
||||
t.Fatalf("New should open with one ask, got %+v", evs)
|
||||
}
|
||||
if s.Multiple != 1 {
|
||||
t.Fatalf("a fresh ladder is worth the stake, got multiple %v", s.Multiple)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// answerRight plays the live question correctly, after `took` on the clock.
|
||||
func answerRight(t *testing.T, s State, took time.Duration) (State, []Event) {
|
||||
t.Helper()
|
||||
q := s.Live()
|
||||
next, evs, err := ApplyMove(s, Move{Choice: q.Correct}, s.AskedAt.Add(took))
|
||||
if err != nil {
|
||||
t.Fatalf("right answer refused: %v", err)
|
||||
}
|
||||
return next, evs
|
||||
}
|
||||
|
||||
func TestNewShufflesButKeepsTheAnswer(t *testing.T) {
|
||||
s := newGame(t, 100, "medium")
|
||||
moved := 0
|
||||
for _, q := range s.Ladder {
|
||||
if q.Answers[q.Correct] != "right" {
|
||||
t.Fatalf("Correct points at %q, not the right answer", q.Answers[q.Correct])
|
||||
}
|
||||
if q.Correct != 0 {
|
||||
moved++
|
||||
}
|
||||
}
|
||||
// All twelve landing on index 0 would mean the shuffle isn't running, and the
|
||||
// game would be "always click the first one".
|
||||
if moved == 0 {
|
||||
t.Fatal("the right answer is first in every question — the shuffle did nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortBankIsRefused(t *testing.T) {
|
||||
if _, _, err := New(100, tier("easy"), 0.05, bank(Rungs-1), epoch, rng()); err != ErrShortLadder {
|
||||
t.Fatalf("a ladder with a missing rung should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The one that matters most: the number the felt quotes is the number the
|
||||
// player is actually paid, at every rung, exactly as in hangman.
|
||||
func TestTheQuoteIsThePayout(t *testing.T) {
|
||||
s := newGame(t, 200, "hard")
|
||||
for rung := 1; rung < Rungs; rung++ {
|
||||
s, _ = answerRight(t, s, 3*time.Second)
|
||||
|
||||
quoted := s.Pays() // what the "take the money" button says it's worth
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("rung %d: walk refused: %v", rung, err)
|
||||
}
|
||||
if banked.Payout != quoted {
|
||||
t.Fatalf("rung %d: the felt quoted %d and the house paid %d", rung, quoted, banked.Payout)
|
||||
}
|
||||
if banked.Phase != PhaseDone || banked.Outcome != OutcomeWalked {
|
||||
t.Fatalf("rung %d: walking should end the game, got %s/%s", rung, banked.Phase, banked.Outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walking before answering anything would be a free look at the first question:
|
||||
// stake, peek, walk, restake until the question is one you happen to know.
|
||||
func TestYouCannotWalkOffTheFirstRung(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, epoch); err != ErrNothingBanked {
|
||||
t.Fatalf("walking on rung 0 should be refused, got %v", err)
|
||||
}
|
||||
|
||||
// One right answer, and now you may.
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
if _, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt); err != nil {
|
||||
t.Fatalf("walking after a right answer should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongAnswerLosesTheLot(t *testing.T) {
|
||||
s := newGame(t, 300, "medium")
|
||||
// Build a decent ladder first, so there is something real to lose.
|
||||
for i := 0; i < 4; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Pays() <= 300 {
|
||||
t.Fatalf("four right answers should be worth more than the stake, got %d", s.Pays())
|
||||
}
|
||||
|
||||
q := s.Live()
|
||||
wrong := (q.Correct + 1) % len(q.Answers)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: wrong}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("a wrong answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeWrong || out.Payout != 0 {
|
||||
t.Fatalf("a wrong answer should pay nothing, got %s/%d", out.Outcome, out.Payout)
|
||||
}
|
||||
if out.Rake != 0 {
|
||||
t.Fatalf("a loss must never be charged a rake, got %d", out.Rake)
|
||||
}
|
||||
if out.Net() != -300 {
|
||||
t.Fatalf("a wrong answer costs the stake and nothing more, got %d", out.Net())
|
||||
}
|
||||
// The player is told which one it was.
|
||||
if evs[0].Kind != "wrong" || evs[0].Correct != q.Correct {
|
||||
t.Fatalf("a wrong answer should reveal the right one, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The clock is the whole anti-google mechanism: running out of it has to cost
|
||||
// as much as being wrong, or leaving the tab open and looking it up wins.
|
||||
func TestTheClockTakesEverything(t *testing.T) {
|
||||
s := newGame(t, 250, "hard")
|
||||
for i := 0; i < 3; i++ {
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
banked := s.Pays()
|
||||
|
||||
q := s.Live()
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Millisecond)
|
||||
out, evs, err := ApplyMove(s, Move{Choice: q.Correct}, late) // the *right* answer, too late
|
||||
if err != nil {
|
||||
t.Fatalf("a late answer is a legal move: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("answering past the limit should time out, got %s", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing — it was worth %d a moment ago, and paid %d", banked, out.Payout)
|
||||
}
|
||||
if evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected a timeout event, got %+v", evs[0])
|
||||
}
|
||||
|
||||
// And answering on the final tick still counts.
|
||||
onTime := s.AskedAt.Add(s.Tier.Clock())
|
||||
if out, _, err = ApplyMove(s, Move{Choice: q.Correct}, onTime); err != nil {
|
||||
t.Fatalf("an answer on the buzzer is legal: %v", err)
|
||||
}
|
||||
if out.Rung != s.Rung+1 {
|
||||
t.Fatal("an answer on the final tick should still count")
|
||||
}
|
||||
}
|
||||
|
||||
// Speed is the only thing separating a slow right answer from a fast one.
|
||||
func TestFasterPaysMore(t *testing.T) {
|
||||
base := newGame(t, 1000, "hard")
|
||||
|
||||
quick, _ := answerRight(t, base, time.Second)
|
||||
slow, _ := answerRight(t, base, 14*time.Second)
|
||||
|
||||
if quick.Multiple <= slow.Multiple {
|
||||
t.Fatalf("a quick answer should be worth more: quick %v, slow %v", quick.Multiple, slow.Multiple)
|
||||
}
|
||||
if quick.Pays() <= slow.Pays() {
|
||||
t.Fatalf("a quick answer should pay more: quick %d, slow %d", quick.Pays(), slow.Pays())
|
||||
}
|
||||
|
||||
// The ends of the scale are the tier's own numbers, and nothing is outside them.
|
||||
instant, _ := answerRight(t, base, 0)
|
||||
buzzer, _ := answerRight(t, base, base.Tier.Clock())
|
||||
if instant.Multiple != base.Tier.Fast {
|
||||
t.Fatalf("an instant answer is worth Fast (%v), got %v", base.Tier.Fast, instant.Multiple)
|
||||
}
|
||||
if buzzer.Multiple != base.Tier.Buzzer {
|
||||
t.Fatalf("an answer on the buzzer is worth Buzzer (%v), got %v", base.Tier.Buzzer, buzzer.Multiple)
|
||||
}
|
||||
if quick.Multiple > base.Tier.Fast || slow.Multiple < base.Tier.Buzzer {
|
||||
t.Fatal("a step escaped the tier's range")
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the ladder ends the run and banks it, rather than leaving the player
|
||||
// on a rung that doesn't exist.
|
||||
func TestClearingTheLadderBanks(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for i := 0; i < Rungs; i++ {
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatalf("the game ended early, on rung %d", i)
|
||||
}
|
||||
s, _ = answerRight(t, s, time.Second)
|
||||
}
|
||||
if s.Outcome != OutcomeCleared {
|
||||
t.Fatalf("twelve right answers should clear the ladder, got %s", s.Outcome)
|
||||
}
|
||||
if s.Rung != Rungs {
|
||||
t.Fatalf("expected to be on rung %d, got %d", Rungs, s.Rung)
|
||||
}
|
||||
if s.Payout != s.Pays() || s.Payout <= s.Bet {
|
||||
t.Fatalf("clearing should bank a win, got payout %d on a %d stake", s.Payout, s.Bet)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Choice: 0}, s.AskedAt); err != ErrGameOver {
|
||||
t.Fatalf("a cleared ladder takes no more moves, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rake comes out of winnings, never out of the stake.
|
||||
func TestRakeOnlyBitesWinnings(t *testing.T) {
|
||||
s := newGame(t, 1000, "medium")
|
||||
s, _ = answerRight(t, s, 0) // instant: multiple is exactly Fast, so the sum is checkable by hand
|
||||
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, s.AskedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
total := int64(float64(1000) * s.Tier.Fast) // 1550
|
||||
profit := total - 1000 // 550
|
||||
rake := int64(float64(profit) * 0.05) // 27
|
||||
want := 1000 + profit - rake // 1523
|
||||
|
||||
if banked.Payout != want {
|
||||
t.Fatalf("payout should be stake + winnings - 5%% of winnings = %d, got %d", want, banked.Payout)
|
||||
}
|
||||
if banked.Rake != rake {
|
||||
t.Fatalf("rake should be %d, got %d", rake, banked.Rake)
|
||||
}
|
||||
if banked.Payout < banked.Bet {
|
||||
t.Fatal("a win handed back less than the stake")
|
||||
}
|
||||
}
|
||||
|
||||
// A move must not scribble on the state it came from — a game has to replay.
|
||||
func TestApplyMoveDoesNotMutateItsInput(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
before := s.Live()
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("move: %v", err)
|
||||
}
|
||||
if s.Rung != 0 || s.Multiple != 1 || s.Phase != PhasePlaying {
|
||||
t.Fatalf("the original state moved underneath us: rung %d multiple %v", s.Rung, s.Multiple)
|
||||
}
|
||||
if next.Rung != 1 {
|
||||
t.Fatalf("the derived state should have climbed a rung, got %d", next.Rung)
|
||||
}
|
||||
// The same move replays to the same place.
|
||||
again, _, err := ApplyMove(s, Move{Choice: before.Correct}, s.AskedAt.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
if again.Multiple != next.Multiple || again.Rung != next.Rung {
|
||||
t.Fatal("the same move from the same state landed somewhere else")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeftCountsDown(t *testing.T) {
|
||||
s := newGame(t, 100, "hard") // 15s
|
||||
if got := s.Left(epoch); got != 15*time.Second {
|
||||
t.Fatalf("a fresh question has the whole clock, got %v", got)
|
||||
}
|
||||
if got := s.Left(epoch.Add(10 * time.Second)); got != 5*time.Second {
|
||||
t.Fatalf("expected 5s left, got %v", got)
|
||||
}
|
||||
// It floors at nought rather than going negative, so a browser can render it.
|
||||
if got := s.Left(epoch.Add(time.Hour)); got != 0 {
|
||||
t.Fatalf("the clock should stop at zero, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageMovesAreRefused(t *testing.T) {
|
||||
s := newGame(t, 100, "easy")
|
||||
for _, choice := range []int{-1, 4, 99} {
|
||||
if _, _, err := ApplyMove(s, Move{Choice: choice}, s.AskedAt); err != ErrUnknownMove {
|
||||
t.Fatalf("choice %d should be refused, got %v", choice, err)
|
||||
}
|
||||
}
|
||||
if s.Phase != PhasePlaying {
|
||||
t.Fatal("a refused move should leave the game alone")
|
||||
}
|
||||
}
|
||||
|
||||
// The clock has to beat the walk button, or it is not a deadline.
|
||||
//
|
||||
// If a dead clock could still be walked away from, the ladder would carry no
|
||||
// risk at all: sit on every question for as long as you like, answer the ones
|
||||
// you can look up, and walk off the ones you can't. The timeout has to be the
|
||||
// first thing that happens to a move.
|
||||
func TestWalkingOffADeadClockIsATimeout(t *testing.T) {
|
||||
s := newGame(t, 500, "hard")
|
||||
s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal
|
||||
|
||||
late := s.AskedAt.Add(s.Tier.Clock() + time.Second)
|
||||
out, evs, err := ApplyMove(s, Move{Walk: true}, late)
|
||||
if err != nil {
|
||||
t.Fatalf("walking after the clock died should resolve, not error: %v", err)
|
||||
}
|
||||
if out.Outcome != OutcomeTimeout {
|
||||
t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome)
|
||||
}
|
||||
if out.Payout != 0 {
|
||||
t.Fatalf("a timeout pays nothing, got %d", out.Payout)
|
||||
}
|
||||
if len(evs) == 0 || evs[0].Kind != "timeout" {
|
||||
t.Fatalf("expected the timeout event first, got %+v", evs)
|
||||
}
|
||||
|
||||
// And the same walk, one tick inside the limit, still banks.
|
||||
intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond)
|
||||
banked, _, err := ApplyMove(s, Move{Walk: true}, intime)
|
||||
if err != nil {
|
||||
t.Fatalf("walk with the clock still running: %v", err)
|
||||
}
|
||||
if banked.Outcome != OutcomeWalked || banked.Payout <= 0 {
|
||||
t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout)
|
||||
}
|
||||
}
|
||||
132
internal/games/uno/bot.go
Normal file
132
internal/games/uno/bot.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package uno
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// The bots.
|
||||
//
|
||||
// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent
|
||||
// company — they hold their wild draw fours back until you're close to going
|
||||
// out, they follow the colour in play when they can, and they get out of the way
|
||||
// of their own hand. Two things changed on the way over:
|
||||
//
|
||||
// The RNG is threaded. gogobee's bots reach for the package global, which is why
|
||||
// its own tests can only assert that a bot played *something* legal. These take
|
||||
// the game's generator, so a bot's choice is part of what a seed replays.
|
||||
//
|
||||
// They are not the same bot at every table. A single deterministic policy is a
|
||||
// puzzle: play round it once and it never surprises you again. So the bot takes
|
||||
// the best card it sees most of the time, and now and then takes the second best
|
||||
// — enough that you cannot count what it is holding by what it plays.
|
||||
|
||||
// botSlip is how often a bot takes its second choice instead of its first. Low
|
||||
// enough that it still plays well, high enough that it isn't a lookup table.
|
||||
const botSlip = 6 // one turn in six
|
||||
|
||||
// botPick chooses a card to play, or reports -1 when the bot has nothing legal.
|
||||
func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) {
|
||||
var playable []int
|
||||
for i, c := range hand {
|
||||
if c.CanPlayOn(top, topColor) {
|
||||
playable = append(playable, i)
|
||||
}
|
||||
}
|
||||
if len(playable) == 0 {
|
||||
return Card{}, -1
|
||||
}
|
||||
|
||||
order := botRank(hand, topColor, playable, minOpponent)
|
||||
pick := order[0]
|
||||
if len(order) > 1 && rng.IntN(botSlip) == 0 {
|
||||
pick = order[1]
|
||||
}
|
||||
return hand[pick], pick
|
||||
}
|
||||
|
||||
// botRank sorts the playable cards best-first, by the bot's own priorities.
|
||||
//
|
||||
// The shape of it: hurt the leader if there is one, otherwise get rid of
|
||||
// something useful and keep the wilds back. A wild draw four spent early is a
|
||||
// wild draw four you don't have when somebody is sitting on one card.
|
||||
func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int {
|
||||
var wd4, wilds, actions, numbers []int
|
||||
for _, i := range playable {
|
||||
switch c := hand[i]; {
|
||||
case c.Value == WildDrawFour:
|
||||
wd4 = append(wd4, i)
|
||||
case c.Value == WildCard:
|
||||
wilds = append(wilds, i)
|
||||
case c.Value.Action():
|
||||
actions = append(actions, i)
|
||||
default:
|
||||
numbers = append(numbers, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Following the colour in play is worth more than not, because it keeps the
|
||||
// bot's hand flexible — so within each group, the cards already in colour go
|
||||
// first.
|
||||
inColorFirst := func(idx []int) []int {
|
||||
var same, other []int
|
||||
for _, i := range idx {
|
||||
if hand[i].Color == topColor {
|
||||
same = append(same, i)
|
||||
} else {
|
||||
other = append(other, i)
|
||||
}
|
||||
}
|
||||
return append(same, other...)
|
||||
}
|
||||
|
||||
var out []int
|
||||
if minOpponent >= 0 && minOpponent <= 2 {
|
||||
// Somebody is about to go out. This is what the +4 was being saved for.
|
||||
out = append(out, wd4...)
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
return out
|
||||
}
|
||||
out = append(out, inColorFirst(actions)...)
|
||||
out = append(out, inColorFirst(numbers)...)
|
||||
out = append(out, wilds...)
|
||||
out = append(out, wd4...)
|
||||
return out
|
||||
}
|
||||
|
||||
// botColor names a colour for a wild: whichever the bot holds most of, so the
|
||||
// card it plays next is one it already has. A hand of nothing but wilds picks
|
||||
// at random rather than always saying red, which would be a tell.
|
||||
func botColor(hand []Card, rng *rand.Rand) Color {
|
||||
counts := [5]int{}
|
||||
for _, c := range hand {
|
||||
if c.Color.Playable() {
|
||||
counts[c.Color]++
|
||||
}
|
||||
}
|
||||
best, bestN := Wild, 0
|
||||
for col := Red; col <= Green; col++ {
|
||||
if counts[col] > bestN {
|
||||
best, bestN = col, counts[col]
|
||||
}
|
||||
}
|
||||
if bestN == 0 {
|
||||
return Red + Color(rng.IntN(4))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
|
||||
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||
func botNames(n int, rng *rand.Rand) []string {
|
||||
pool := append([]string(nil), botPool...)
|
||||
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
if n > len(pool) {
|
||||
n = len(pool)
|
||||
}
|
||||
return pool[:n]
|
||||
}
|
||||
|
||||
var botPool = []string{
|
||||
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
||||
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
||||
}
|
||||
827
internal/games/uno/uno.go
Normal file
827
internal/games/uno/uno.go
Normal file
@@ -0,0 +1,827 @@
|
||||
// Package uno is a pure UNO engine, played for chips against bots.
|
||||
//
|
||||
// Same seam as the other four tables: ApplyMove(state, move) (state, events,
|
||||
// error), where an error means the move was illegal and nothing else. No HTTP,
|
||||
// no timers, no sockets, no player names off the wire. The state is a plain
|
||||
// value, so a game survives a redeploy and replays from its seed.
|
||||
//
|
||||
// Two things make UNO different from the tables already on the felt.
|
||||
//
|
||||
// The bots move inside ApplyMove. A turn-based game against opponents is
|
||||
// normally where you reach for a socket, and the plan says solo UNO must not:
|
||||
// so one call from the browser plays the player's move *and* every bot turn that
|
||||
// follows it, and hands back the whole run as events. The table animates them in
|
||||
// order. The browser is never waiting on the server to think of something.
|
||||
//
|
||||
// The RNG is in the state, not an argument. The bots make choices and a spent
|
||||
// deck gets reshuffled, so the engine needs randomness mid-game — but a reducer
|
||||
// that takes an rng is a reducer whose caller has to keep one alive across
|
||||
// requests, and there isn't one: every move is a fresh process for all it knows.
|
||||
// So the seed rides in the state (which never leaves the server; the deck is in
|
||||
// there too) and each step derives its own generator from seed and step count.
|
||||
// Value in, value out, and the game still replays exactly as it was dealt.
|
||||
package uno
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
// Errors an illegal move can produce.
|
||||
var (
|
||||
ErrGameOver = errors.New("uno: the game is already over")
|
||||
ErrNotYourTurn = errors.New("uno: it isn't your turn")
|
||||
ErrNoSuchCard = errors.New("uno: you don't have that card")
|
||||
ErrCantPlay = errors.New("uno: that card can't go on this one")
|
||||
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
||||
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
|
||||
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
|
||||
ErrUnknownMove = errors.New("uno: unknown move")
|
||||
ErrBadBet = errors.New("uno: bet must be positive")
|
||||
ErrUnknownTier = errors.New("uno: no such tier")
|
||||
)
|
||||
|
||||
// You are always seat zero. The bots are the seats after you.
|
||||
const You = 0
|
||||
|
||||
// HandSize is the deal. Seven each, as printed on the box.
|
||||
const HandSize = 7
|
||||
|
||||
// Color is a card's colour. Wild has none until it's played.
|
||||
//
|
||||
// Wild is deliberately the zero value. A wild played with no colour named is the
|
||||
// one move in this game that must never be allowed to mean something, and a
|
||||
// browser that leaves `color` out of the JSON sends a zero — so the zero has to
|
||||
// be "no colour", not red. It was red for about an hour, and a wild with the
|
||||
// field missing quietly went down as a red one.
|
||||
type Color uint8
|
||||
|
||||
const (
|
||||
Wild Color = iota
|
||||
Red
|
||||
Blue
|
||||
Yellow
|
||||
Green
|
||||
)
|
||||
|
||||
var colorNames = [5]string{"wild", "red", "blue", "yellow", "green"}
|
||||
|
||||
func (c Color) String() string {
|
||||
if c > Green {
|
||||
return "?"
|
||||
}
|
||||
return colorNames[c]
|
||||
}
|
||||
|
||||
// Playable reports whether a colour is one a wild may name — Wild itself isn't.
|
||||
func (c Color) Playable() bool { return c >= Red && c <= Green }
|
||||
|
||||
// Value is what's printed on the face.
|
||||
type Value uint8
|
||||
|
||||
const (
|
||||
Zero Value = iota
|
||||
One
|
||||
Two
|
||||
Three
|
||||
Four
|
||||
Five
|
||||
Six
|
||||
Seven
|
||||
Eight
|
||||
Nine
|
||||
Skip
|
||||
Reverse
|
||||
DrawTwo
|
||||
WildCard
|
||||
WildDrawFour
|
||||
)
|
||||
|
||||
var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||
"skip", "reverse", "+2", "wild", "+4"}
|
||||
|
||||
func (v Value) String() string {
|
||||
if v > WildDrawFour {
|
||||
return "?"
|
||||
}
|
||||
return valueNames[v]
|
||||
}
|
||||
|
||||
// Action reports whether a card does something beyond being a number.
|
||||
func (v Value) Action() bool { return v >= Skip }
|
||||
|
||||
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
|
||||
// poll, and a state holds all 108.
|
||||
type Card struct {
|
||||
Color Color `json:"c"`
|
||||
Value Value `json:"v"`
|
||||
}
|
||||
|
||||
// IsWild reports whether the card has no colour of its own.
|
||||
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
|
||||
|
||||
// CanPlayOn is the whole rule of UNO: match the colour in play, or match the
|
||||
// face, or be a wild. Note it takes the colour *in play* rather than the top
|
||||
// card's own colour — after a wild those are different, and the one that counts
|
||||
// is the colour that was named.
|
||||
func (c Card) CanPlayOn(top Card, topColor Color) bool {
|
||||
if c.IsWild() {
|
||||
return true
|
||||
}
|
||||
return c.Color == topColor || c.Value == top.Value
|
||||
}
|
||||
|
||||
// NewDeck builds the 108: one zero and two each of 1-9, skip, reverse and +2 in
|
||||
// every colour, plus four wilds and four wild draw fours. Unshuffled — Deal
|
||||
// shuffles, and a test wants the fixed order.
|
||||
func NewDeck() []Card {
|
||||
d := make([]Card, 0, 108)
|
||||
for _, col := range []Color{Red, Blue, Yellow, Green} {
|
||||
d = append(d, Card{col, Zero})
|
||||
for v := One; v <= DrawTwo; v++ {
|
||||
d = append(d, Card{col, v}, Card{col, v})
|
||||
}
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
d = append(d, Card{Wild, WildCard}, Card{Wild, WildDrawFour})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Tier is a table, and the table size *is* the difficulty. More bots is a longer
|
||||
// shot — three of them going out before you is three ways to lose — so it pays
|
||||
// more. This is the tier dial every other game here has, pointed at the one knob
|
||||
// UNO actually has.
|
||||
type Tier struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Bots int `json:"bots"`
|
||||
Base float64 `json:"base"` // what going out first pays, before the rake
|
||||
Blurb string `json:"blurb"`
|
||||
}
|
||||
|
||||
// Tiers are the three tables.
|
||||
//
|
||||
// The multiples are not guesses. A player who simply plays the first legal card
|
||||
// they hold — which is a real strategy, and a bad one — goes out first 43% of
|
||||
// the time heads up, 32% at three seats and 27% at four. These pay a little
|
||||
// under what that costs, so bad play loses slowly and good play (holding the
|
||||
// wilds, dumping the colour you're long in, counting what a bot picked up) is
|
||||
// worth roughly the house's edge. That is the game being about something.
|
||||
var Tiers = []Tier{
|
||||
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
|
||||
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
|
||||
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
|
||||
Blurb: "Two bots. Twice the +4s pointed at you."},
|
||||
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
|
||||
Blurb: "Three bots, and any of them going out first takes your stake."},
|
||||
}
|
||||
|
||||
// TierBySlug finds a tier 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
|
||||
}
|
||||
|
||||
// Phase is where the game is.
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhasePlay Phase = "play" // your turn, play or draw
|
||||
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
|
||||
PhaseDone Phase = "done"
|
||||
)
|
||||
|
||||
// Outcome is how it ended.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeNone Outcome = ""
|
||||
OutcomeWon Outcome = "won" // you went out first
|
||||
OutcomeLost Outcome = "lost" // a bot did
|
||||
OutcomeStuck Outcome = "stuck" // nobody can move and there are no cards left
|
||||
)
|
||||
|
||||
// Won reports whether this outcome pays.
|
||||
func (o Outcome) Won() bool { return o == OutcomeWon }
|
||||
|
||||
// State is one game. The bots' hands and the deck are in here, which is exactly
|
||||
// why this value never crosses the wire — the browser gets counts instead.
|
||||
type State struct {
|
||||
Tier Tier `json:"tier"`
|
||||
Hands [][]Card `json:"hands"` // seat 0 is you; the rest are bots
|
||||
Bots []string `json:"bots"` // their names, one per bot seat (seat i is Bots[i-1])
|
||||
Deck []Card `json:"deck"`
|
||||
Discard []Card `json:"discard"` // the top card is the last one
|
||||
Color Color `json:"color"` // the colour in play, which a wild renames
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
|
||||
|
||||
Seed1 uint64 `json:"seed1"`
|
||||
Seed2 uint64 `json:"seed2"`
|
||||
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
|
||||
|
||||
RakePct float64 `json:"rake_pct"`
|
||||
Bet int64 `json:"bet"`
|
||||
Phase Phase `json:"phase"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
Payout int64 `json:"payout"`
|
||||
Rake int64 `json:"rake"`
|
||||
}
|
||||
|
||||
// Event is something the table animates. The bots' turns arrive as a run of
|
||||
// these on the back of the player's own move, and the felt plays them in order.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"` // see below
|
||||
Seat int `json:"seat"` // who it happened to
|
||||
Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew
|
||||
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
|
||||
N int `json:"n,omitempty"` // how many cards were drawn
|
||||
Left int `json:"left"` // cards left in that seat's hand afterwards
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// The kinds an Event comes in.
|
||||
//
|
||||
// deal the hands are dealt and the first card turned over
|
||||
// play a card goes on the pile
|
||||
// wild the colour was named (rides with the play it belongs to)
|
||||
// draw cards come off the deck. A bot's are face down: Card is nil.
|
||||
// forced the same, but not by choice — a +2 or a +4 landed on them
|
||||
// pass the turn moves on with nothing played
|
||||
// skip a seat loses its turn
|
||||
// reverse the direction flips
|
||||
// uno a hand is down to one card
|
||||
// reshuffle the discard goes back under
|
||||
// settle it's over
|
||||
const (
|
||||
EvDeal = "deal"
|
||||
EvPlay = "play"
|
||||
EvDraw = "draw"
|
||||
EvForced = "forced"
|
||||
EvPass = "pass"
|
||||
EvSkip = "skip"
|
||||
EvReverse = "reverse"
|
||||
EvUno = "uno"
|
||||
EvReshuffle = "reshuffle"
|
||||
EvSettle = "settle"
|
||||
)
|
||||
|
||||
// Move is what the player sends: play this card, take one off the deck, or —
|
||||
// having taken one you can play — decline to play it.
|
||||
type Move struct {
|
||||
Kind string `json:"kind"` // "play" | "draw" | "pass"
|
||||
Index int `json:"index"` // which card of your hand, for a play
|
||||
Color Color `json:"color"` // the colour you name, for a wild
|
||||
}
|
||||
|
||||
// Move kinds.
|
||||
const (
|
||||
MovePlay = "play"
|
||||
MoveDraw = "draw"
|
||||
MovePass = "pass"
|
||||
)
|
||||
|
||||
// New deals a game: a shuffled deck, seven each, and a card turned over.
|
||||
//
|
||||
// The turned card is dealt until it's a number. The official rules have the
|
||||
// first player eat a +2 that lands there, and turn a wild into a colour vote —
|
||||
// both of which are a game that opens by doing something to you before you have
|
||||
// touched it. A number card up top is the same game, minus the paperwork.
|
||||
func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
||||
if bet <= 0 {
|
||||
return State{}, nil, ErrBadBet
|
||||
}
|
||||
if t.Bots < 1 {
|
||||
return State{}, nil, ErrUnknownTier
|
||||
}
|
||||
rng := stepRNG(seed1, seed2, 0)
|
||||
|
||||
deck := NewDeck()
|
||||
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||
|
||||
s := State{
|
||||
Tier: t, Deck: deck, Dir: 1, Turn: You,
|
||||
Seed1: seed1, Seed2: seed2,
|
||||
RakePct: rakePct, Bet: bet, Phase: PhasePlay,
|
||||
Bots: botNames(t.Bots, rng),
|
||||
}
|
||||
|
||||
seats := t.Bots + 1
|
||||
s.Hands = make([][]Card, seats)
|
||||
for i := range s.Hands {
|
||||
s.Hands[i] = make([]Card, 0, HandSize)
|
||||
}
|
||||
for c := 0; c < HandSize; c++ {
|
||||
for seat := 0; seat < seats; seat++ {
|
||||
card, _ := s.pop()
|
||||
s.Hands[seat] = append(s.Hands[seat], card)
|
||||
}
|
||||
}
|
||||
|
||||
// Turn cards over until one of them is a plain number.
|
||||
for {
|
||||
card, ok := s.pop()
|
||||
if !ok {
|
||||
return State{}, nil, errors.New("uno: deck ran out on the deal") // 108 cards; unreachable
|
||||
}
|
||||
if card.Value.Action() {
|
||||
s.Discard = append(s.Discard, card) // it stays buried, out of play
|
||||
continue
|
||||
}
|
||||
s.Discard = append(s.Discard, card)
|
||||
s.Color = card.Color
|
||||
break
|
||||
}
|
||||
|
||||
return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil
|
||||
}
|
||||
|
||||
// ApplyMove is the engine. Your move goes in; your move, and every bot turn it
|
||||
// hands off to, comes back out. An error means the move was illegal and the
|
||||
// caller's state is untouched.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrGameOver
|
||||
}
|
||||
if s.Turn != You {
|
||||
// Can't happen through this door — ApplyMove always runs the bots out
|
||||
// before it returns — but a state restored from a row that predates a bug
|
||||
// shouldn't wedge the player, it should say what's wrong.
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
|
||||
next := s.clone()
|
||||
next.Step++
|
||||
rng := stepRNG(next.Seed1, next.Seed2, next.Step)
|
||||
|
||||
var evs []Event
|
||||
var err error
|
||||
switch m.Kind {
|
||||
case MovePlay:
|
||||
evs, err = next.playerPlays(m, rng)
|
||||
case MoveDraw:
|
||||
evs, err = next.playerDraws(rng)
|
||||
case MovePass:
|
||||
evs, err = next.playerPasses()
|
||||
default:
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
if err != nil {
|
||||
return s, nil, err // the caller's state, untouched
|
||||
}
|
||||
|
||||
// The bots take their turns on the back of yours, and the whole run comes
|
||||
// back as one script. This is the reason solo UNO needs no socket.
|
||||
next.runBots(&evs, rng)
|
||||
|
||||
// And if that left a table nobody can move at, it ends here rather than
|
||||
// handing back a turn that has nothing in it. See stalled().
|
||||
if next.Phase != PhaseDone && next.stalled() {
|
||||
next.stuck(&evs)
|
||||
}
|
||||
return next, evs, nil
|
||||
}
|
||||
|
||||
// playerPlays puts one of your cards on the pile.
|
||||
func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
||||
hand := s.Hands[You]
|
||||
if m.Index < 0 || m.Index >= len(hand) {
|
||||
return nil, ErrNoSuchCard
|
||||
}
|
||||
// Having drawn a playable card, the only card you may play is that one. Being
|
||||
// allowed to draw and *then* play something else would make drawing a free
|
||||
// look at the deck with no cost attached.
|
||||
if s.Phase == PhaseDrawn && m.Index != len(hand)-1 {
|
||||
return nil, ErrMustPlayNow
|
||||
}
|
||||
card := hand[m.Index]
|
||||
if !card.CanPlayOn(s.top(), s.Color) {
|
||||
return nil, ErrCantPlay
|
||||
}
|
||||
if card.IsWild() && !m.Color.Playable() {
|
||||
return nil, ErrNeedColor
|
||||
}
|
||||
|
||||
s.Hands[You] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...)
|
||||
var evs []Event
|
||||
s.discard(You, card, m.Color, &evs)
|
||||
s.after(You, card, &evs, rng)
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// playerDraws takes one off the deck. If it can be played you get the choice —
|
||||
// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it
|
||||
// can't, the turn passes on the spot: there is nothing to decide.
|
||||
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
|
||||
if s.Phase == PhaseDrawn {
|
||||
return nil, ErrMustPlayNow // you already drew; play it or pass
|
||||
}
|
||||
var evs []Event
|
||||
drawn := s.deal(You, 1, false, &evs, rng)
|
||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
s.Phase = PhaseDrawn
|
||||
return evs, nil
|
||||
}
|
||||
evs = append(evs, Event{Kind: EvPass, Seat: You})
|
||||
s.advance(1)
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// playerPasses declines the card you just drew.
|
||||
func (s *State) playerPasses() ([]Event, error) {
|
||||
if s.Phase != PhaseDrawn {
|
||||
return nil, ErrCantPass
|
||||
}
|
||||
s.Phase = PhasePlay
|
||||
s.advance(1)
|
||||
return []Event{{Kind: EvPass, Seat: You}}, nil
|
||||
}
|
||||
|
||||
// runBots plays every bot turn between you and your next one. It stops the
|
||||
// moment the game is over, the turn comes back round, or the table dies under
|
||||
// it — a stalled table would otherwise pass the turn round and round forever
|
||||
// without ever reaching you.
|
||||
func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
|
||||
for s.Phase != PhaseDone && s.Turn != You && !s.stalled() {
|
||||
s.botTurn(s.Turn, evs, rng)
|
||||
}
|
||||
}
|
||||
|
||||
// botTurn plays one bot's turn.
|
||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
||||
if idx < 0 {
|
||||
// Nothing playable: draw one, and play it if it happens to go.
|
||||
drawn := s.deal(seat, 1, false, evs, rng)
|
||||
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||
s.advance(1)
|
||||
return
|
||||
}
|
||||
card, idx = drawn[0], len(s.Hands[seat])-1
|
||||
}
|
||||
|
||||
hand := s.Hands[seat]
|
||||
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
||||
|
||||
color := card.Color
|
||||
if card.IsWild() {
|
||||
color = botColor(s.Hands[seat], rng)
|
||||
}
|
||||
s.discard(seat, card, color, evs)
|
||||
s.after(seat, card, evs, rng)
|
||||
}
|
||||
|
||||
// stalled reports whether the table is dead: nothing left to draw anywhere, and
|
||||
// not one seat holding a card that goes on the pile.
|
||||
//
|
||||
// This is the condition, tested directly. It used to be guessed at by counting
|
||||
// how many bots had passed in a row, which could not work: runBots hands the
|
||||
// turn back the moment it comes round to you, so the count never got as high as
|
||||
// the number of seats, and your own empty-handed pass was never in it. The guard
|
||||
// never fired once. A game that can't end is worse than one that ends badly —
|
||||
// and worse than either, a live game you can't finish is chips you can't cash
|
||||
// out, because the cage won't let you leave a hand half-played.
|
||||
func (s State) stalled() bool {
|
||||
if len(s.Deck) > 0 || len(s.Discard) > 1 {
|
||||
return false // there is a card to draw, or a discard to make one out of
|
||||
}
|
||||
for _, hand := range s.Hands {
|
||||
for _, c := range hand {
|
||||
if c.CanPlayOn(s.top(), s.Color) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// discard puts a card on the pile and names the colour now in play.
|
||||
//
|
||||
// A wild is stamped with the colour it was played as, so the pile shows what was
|
||||
// called rather than a black card and a note beside it. That stamp is undone if
|
||||
// the card ever comes back out — see reshuffle, which would otherwise bleed four
|
||||
// extra reds into the deck.
|
||||
func (s *State) discard(seat int, card Card, color Color, evs *[]Event) {
|
||||
if card.IsWild() {
|
||||
s.Color = color
|
||||
card.Color = color
|
||||
} else {
|
||||
s.Color = card.Color
|
||||
}
|
||||
s.Discard = append(s.Discard, card)
|
||||
e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])}
|
||||
*evs = append(*evs, e)
|
||||
if len(s.Hands[seat]) == 1 {
|
||||
*evs = append(*evs, Event{Kind: EvUno, Seat: seat})
|
||||
}
|
||||
}
|
||||
|
||||
// after resolves what the card just played does, and moves the turn on. It is
|
||||
// the one place the rules of skip, reverse and the draw cards live.
|
||||
func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
||||
if len(s.Hands[seat]) == 0 {
|
||||
s.settle(seat, evs)
|
||||
return
|
||||
}
|
||||
s.Phase = PhasePlay
|
||||
|
||||
switch card.Value {
|
||||
case Skip:
|
||||
victim := s.seatAt(1)
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
|
||||
s.advance(2)
|
||||
|
||||
case Reverse:
|
||||
// Two at the table and a reverse has nobody to hand the turn back to, so it
|
||||
// is a skip — which, with two players, means you go again.
|
||||
if len(s.Hands) == 2 {
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
|
||||
s.advance(2)
|
||||
return
|
||||
}
|
||||
s.Dir = -s.Dir
|
||||
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
||||
s.advance(1)
|
||||
|
||||
case DrawTwo:
|
||||
s.punish(s.seatAt(1), 2, evs, rng)
|
||||
|
||||
case WildDrawFour:
|
||||
s.punish(s.seatAt(1), 4, evs, rng)
|
||||
|
||||
default:
|
||||
s.advance(1)
|
||||
}
|
||||
}
|
||||
|
||||
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
|
||||
// +2 played onto a +2 is a house rule, and the one this table plays is the one
|
||||
// on the box.
|
||||
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
|
||||
s.deal(victim, n, true, evs, rng)
|
||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
|
||||
s.advance(2)
|
||||
}
|
||||
|
||||
// deal gives a seat n cards, reshuffling the discard back under the deck if it
|
||||
// runs dry. The cards it hands back are the ones actually drawn — which can be
|
||||
// fewer than asked for, when there is nothing left anywhere to draw.
|
||||
//
|
||||
// A bot's cards go face down: the event carries the count, never the card. The
|
||||
// only hand whose faces cross the wire is yours.
|
||||
func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card {
|
||||
got := make([]Card, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
|
||||
break
|
||||
}
|
||||
c, ok := s.pop()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
s.Hands[seat] = append(s.Hands[seat], c)
|
||||
got = append(got, c)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
return got
|
||||
}
|
||||
kind := EvDraw
|
||||
if forced {
|
||||
kind = EvForced
|
||||
}
|
||||
e := Event{Kind: kind, Seat: seat, N: len(got), Left: len(s.Hands[seat])}
|
||||
if seat == You && len(got) == 1 {
|
||||
c := got[0]
|
||||
e.Card = &c // your own card, and only yours, comes face up
|
||||
}
|
||||
*evs = append(*evs, e)
|
||||
return got
|
||||
}
|
||||
|
||||
// reshuffle turns the discard back into a deck, keeping the card in play on top
|
||||
// of the pile. It reports whether there was anything to reshuffle.
|
||||
func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool {
|
||||
if len(s.Discard) < 2 {
|
||||
return false // nothing under the top card: the table is out of cards
|
||||
}
|
||||
top := s.Discard[len(s.Discard)-1]
|
||||
rest := append([]Card(nil), s.Discard[:len(s.Discard)-1]...)
|
||||
rng.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] })
|
||||
|
||||
// A wild goes back in as a wild. It was played as a colour, and leaving that
|
||||
// colour stamped on it would quietly bleed four extra reds into the deck.
|
||||
for i := range rest {
|
||||
if rest[i].Value == WildCard || rest[i].Value == WildDrawFour {
|
||||
rest[i].Color = Wild
|
||||
}
|
||||
}
|
||||
s.Deck = rest
|
||||
s.Discard = []Card{top}
|
||||
*evs = append(*evs, Event{Kind: EvReshuffle, N: len(rest)})
|
||||
return true
|
||||
}
|
||||
|
||||
// settle ends the game. Going out first pays the tier; anyone else going out
|
||||
// takes the stake. The rake, as everywhere in this casino, comes out of the
|
||||
// winnings and never out of the stake.
|
||||
func (s *State) settle(winner int, evs *[]Event) {
|
||||
s.Phase = PhaseDone
|
||||
if winner == You {
|
||||
s.Outcome = OutcomeWon
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Outcome = OutcomeLost
|
||||
s.Payout = 0
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSettle, Seat: winner, Text: string(s.Outcome)})
|
||||
}
|
||||
|
||||
// stuck ends a game nobody can move in: the deck is spent, the discard is one
|
||||
// card deep, and every seat has passed. The shortest hand takes it — and a tie
|
||||
// is not a win, because a win here has to be somebody actually going out.
|
||||
func (s *State) stuck(evs *[]Event) {
|
||||
best, tied := 0, false
|
||||
for seat := range s.Hands {
|
||||
switch {
|
||||
case len(s.Hands[seat]) < len(s.Hands[best]):
|
||||
best, tied = seat, false
|
||||
case seat != best && len(s.Hands[seat]) == len(s.Hands[best]):
|
||||
tied = true
|
||||
}
|
||||
}
|
||||
s.Phase = PhaseDone
|
||||
if best == You && !tied {
|
||||
s.Outcome = OutcomeWon
|
||||
s.Payout = s.Pays()
|
||||
s.Rake = s.rakeNow()
|
||||
} else {
|
||||
s.Outcome = OutcomeStuck
|
||||
s.Payout = 0
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: EvSettle, Seat: best, Text: string(s.Outcome)})
|
||||
}
|
||||
|
||||
// Pays is what going out *right now* would put back on the player's stack: the
|
||||
// stake, plus the winnings, less the house's cut of the winnings.
|
||||
//
|
||||
// It exists because the felt quotes this number while the game is still running,
|
||||
// and settle() is the only other thing that works it out. Hangman learned this
|
||||
// the hard way: two sums drift, and the table ends up advertising a payout it
|
||||
// doesn't honour. So settle calls this rather than doing it again.
|
||||
func (s State) Pays() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
|
||||
if total < s.Bet {
|
||||
total = s.Bet
|
||||
}
|
||||
profit := total - s.Bet
|
||||
if profit > 0 {
|
||||
profit -= s.rakeOn(profit)
|
||||
}
|
||||
return s.Bet + profit
|
||||
}
|
||||
|
||||
// rakeNow is the other half of what Pays works out: the house's cut of a win
|
||||
// taken right now.
|
||||
func (s State) rakeNow() int64 {
|
||||
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
|
||||
if total <= s.Bet {
|
||||
return 0
|
||||
}
|
||||
return s.rakeOn(total - s.Bet)
|
||||
}
|
||||
|
||||
func (s State) rakeOn(profit int64) int64 {
|
||||
rake := int64(math.Floor(float64(profit) * s.RakePct))
|
||||
if rake < 0 {
|
||||
return 0
|
||||
}
|
||||
return rake
|
||||
}
|
||||
|
||||
// Net is what the game did to the player's stack.
|
||||
func (s State) Net() int64 {
|
||||
if s.Phase != PhaseDone {
|
||||
return 0
|
||||
}
|
||||
return s.Payout - s.Bet
|
||||
}
|
||||
|
||||
// Playable reports which cards of your hand can legally go on the pile. The
|
||||
// browser lights these up: being shown what you can play is the game teaching
|
||||
// you, and the server still decides every move regardless.
|
||||
//
|
||||
// While you're sitting on a card you just drew, that card is the only one you
|
||||
// may play — so it is the only one that lights up.
|
||||
func (s State) Playable() []int {
|
||||
if s.Phase == PhaseDone || s.Turn != You {
|
||||
return nil
|
||||
}
|
||||
hand := s.Hands[You]
|
||||
if s.Phase == PhaseDrawn {
|
||||
if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) {
|
||||
return []int{len(hand) - 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
if c.CanPlayOn(s.top(), s.Color) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Counts is how many cards each seat holds — what the browser gets instead of
|
||||
// the bots' hands.
|
||||
func (s State) Counts() []int {
|
||||
out := make([]int, len(s.Hands))
|
||||
for i := range s.Hands {
|
||||
out[i] = len(s.Hands[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Top is the card in play.
|
||||
func (s State) Top() Card { return s.top() }
|
||||
|
||||
// Left is how many cards are still in the deck.
|
||||
func (s State) Left() int { return len(s.Deck) }
|
||||
|
||||
// ---- the plumbing ---------------------------------------------------------
|
||||
|
||||
func (s State) top() Card {
|
||||
if len(s.Discard) == 0 {
|
||||
return Card{}
|
||||
}
|
||||
return s.Discard[len(s.Discard)-1]
|
||||
}
|
||||
|
||||
func (s State) topPtr() *Card {
|
||||
c := s.top()
|
||||
return &c
|
||||
}
|
||||
|
||||
// pop takes the next card off the deck.
|
||||
func (s *State) pop() (Card, bool) {
|
||||
if len(s.Deck) == 0 {
|
||||
return Card{}, false
|
||||
}
|
||||
c := s.Deck[0]
|
||||
s.Deck = s.Deck[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// seatAt is the seat n places round from the one whose turn it is.
|
||||
func (s State) seatAt(n int) int {
|
||||
seats := len(s.Hands)
|
||||
return ((s.Turn+s.Dir*n)%seats + seats) % seats
|
||||
}
|
||||
|
||||
// advance moves the turn on n places.
|
||||
func (s *State) advance(n int) { s.Turn = s.seatAt(n) }
|
||||
|
||||
// minOpponent is the smallest hand at the table that isn't this seat's — how
|
||||
// close the bot is to being beaten, which is the only thing it plays around.
|
||||
func (s State) minOpponent(seat int) int {
|
||||
min := -1
|
||||
for i := range s.Hands {
|
||||
if i == seat {
|
||||
continue
|
||||
}
|
||||
if min < 0 || len(s.Hands[i]) < min {
|
||||
min = len(s.Hands[i])
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// clone deep-copies everything a move can touch, so a derived state shares no
|
||||
// backing array with the one it came from.
|
||||
func (s State) clone() State {
|
||||
hands := make([][]Card, len(s.Hands))
|
||||
for i, h := range s.Hands {
|
||||
hands[i] = append([]Card(nil), h...)
|
||||
}
|
||||
s.Hands = hands
|
||||
s.Deck = append([]Card(nil), s.Deck...)
|
||||
s.Discard = append([]Card(nil), s.Discard...)
|
||||
s.Bots = append([]string(nil), s.Bots...)
|
||||
return s
|
||||
}
|
||||
|
||||
// stepRNG is the generator for one step of the game. The seed is the game's;
|
||||
// the step number is what stops every move from replaying the same numbers.
|
||||
// Mixing with the golden ratio's odd 64-bit constant keeps consecutive steps
|
||||
// from producing streams that share a bit pattern.
|
||||
func stepRNG(seed1, seed2, step uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15)))
|
||||
}
|
||||
787
internal/games/uno/uno_test.go
Normal file
787
internal/games/uno/uno_test.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
||||
func full() Tier { t, _ := TierBySlug("full"); return t }
|
||||
func table() Tier { t, _ := TierBySlug("table"); return t }
|
||||
|
||||
// deal starts a game, failing the test if it can't.
|
||||
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
|
||||
t.Helper()
|
||||
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != EvDeal {
|
||||
t.Fatalf("New should deal exactly one event, got %+v", evs)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// census counts every card in the game, wherever it is. It is the invariant the
|
||||
// whole engine has to hold: 108 cards, each of them in exactly one place.
|
||||
func census(s State) map[Card]int {
|
||||
m := map[Card]int{}
|
||||
for _, h := range s.Hands {
|
||||
for _, c := range h {
|
||||
m[c]++
|
||||
}
|
||||
}
|
||||
for _, c := range s.Deck {
|
||||
m[c]++
|
||||
}
|
||||
for _, c := range s.Discard {
|
||||
// A wild is stamped with the colour it was played as while it sits on the
|
||||
// pile, so it counts as the wild it really is.
|
||||
if c.Value == WildCard || c.Value == WildDrawFour {
|
||||
c.Color = Wild
|
||||
}
|
||||
m[c]++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func total(m map[Card]int) int {
|
||||
n := 0
|
||||
for _, v := range m {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestNewDeckIsADeck(t *testing.T) {
|
||||
m := census(State{Deck: NewDeck()})
|
||||
if got := total(m); got != 108 {
|
||||
t.Fatalf("deck has %d cards, want 108", got)
|
||||
}
|
||||
if m[Card{Red, Zero}] != 1 {
|
||||
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
|
||||
}
|
||||
if m[Card{Blue, Seven}] != 2 {
|
||||
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
|
||||
}
|
||||
if m[Card{Wild, WildDrawFour}] != 4 {
|
||||
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeals(t *testing.T) {
|
||||
s := deal(t, full(), 100, 7)
|
||||
if len(s.Hands) != 4 {
|
||||
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h) != HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
||||
}
|
||||
}
|
||||
if len(s.Bots) != 3 {
|
||||
t.Fatalf("want three bot names, got %v", s.Bots)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Errorf("you play first, turn is %d", s.Turn)
|
||||
}
|
||||
if got := total(census(s)); got != 108 {
|
||||
t.Fatalf("the deal lost cards: %d of 108", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The card turned over to start is never an action card — see New.
|
||||
func TestOpeningCardIsANumber(t *testing.T) {
|
||||
for seed := uint64(0); seed < 300; seed++ {
|
||||
s := deal(t, table(), 50, seed)
|
||||
if s.Top().Value.Action() {
|
||||
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
||||
}
|
||||
if s.Color != s.Top().Color {
|
||||
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules ------------------------------------------------------------
|
||||
|
||||
// rig builds a state by hand, so a rule can be tested without hunting a seed
|
||||
// that happens to deal it.
|
||||
//
|
||||
// The deck is the rest of the deck: every card not in a hand and not the one in
|
||||
// play. So a rigged game still holds 108 cards, and the census invariant means
|
||||
// something in these tests too.
|
||||
func rig(hands [][]Card, top Card, color Color) State {
|
||||
left := map[Card]int{}
|
||||
for _, c := range NewDeck() {
|
||||
left[c]++
|
||||
}
|
||||
take := func(c Card) {
|
||||
if c.IsWild() {
|
||||
c.Color = Wild
|
||||
}
|
||||
left[c]--
|
||||
}
|
||||
for _, h := range hands {
|
||||
for _, c := range h {
|
||||
take(c)
|
||||
}
|
||||
}
|
||||
take(top)
|
||||
|
||||
var deck []Card
|
||||
for _, c := range NewDeck() {
|
||||
key := c
|
||||
if left[key] > 0 {
|
||||
left[key]--
|
||||
deck = append(deck, c)
|
||||
}
|
||||
}
|
||||
return State{
|
||||
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
|
||||
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
|
||||
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMustMatch(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||
// Same face, different colour: legal.
|
||||
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
|
||||
}
|
||||
if next.Color != Blue {
|
||||
t.Errorf("colour in play should follow the card: %v", next.Color)
|
||||
}
|
||||
if evs[0].Kind != EvPlay || evs[0].Seat != You {
|
||||
t.Errorf("first event should be your play, got %+v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNeedsAColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNamesTheColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||
if err != nil {
|
||||
t.Fatalf("play wild: %v", err)
|
||||
}
|
||||
// The bot moved after us, so the colour in play is whatever it left behind —
|
||||
// what we can check is that the wild itself went down as green.
|
||||
top := next.Discard
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
||||
}
|
||||
if top[1] != (Card{Green, WildCard}) {
|
||||
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||
// Two seats, so the +2 lands on the bot and the turn comes straight back.
|
||||
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
if len(next.Hands[1]) != 4 {
|
||||
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
|
||||
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("the +2 lost cards: %d of 108", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != 1 {
|
||||
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
|
||||
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
|
||||
}
|
||||
if len(next.Hands[1]) != 1 {
|
||||
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
// Every bot holds a red card, so each of them can play the moment the turn
|
||||
// reaches it — which is what makes the *order* they play in observable.
|
||||
s := rig([][]Card{
|
||||
{{Red, Reverse}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
{{Red, Seven}, {Yellow, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play reverse: %v", err)
|
||||
}
|
||||
if next.Dir != -1 {
|
||||
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
|
||||
}
|
||||
if !hasKind(evs, EvReverse) {
|
||||
t.Errorf("want a reverse event: %+v", evs)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
||||
}
|
||||
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
|
||||
var order []int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat != You {
|
||||
order = append(order, e.Seat)
|
||||
}
|
||||
}
|
||||
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
|
||||
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipSkips(t *testing.T) {
|
||||
// Both bots hold a playable red, so the only reason either of them doesn't
|
||||
// play is that it wasn't asked to.
|
||||
s := rig([][]Card{
|
||||
{{Red, Skip}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
s.Tier = table()
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvSkip) {
|
||||
t.Errorf("want a skip event: %+v", evs)
|
||||
}
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat == 1 {
|
||||
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
|
||||
}
|
||||
}
|
||||
if len(next.Hands[1]) != 2 {
|
||||
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
|
||||
}
|
||||
if len(next.Hands[2]) != 1 {
|
||||
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
|
||||
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseDrawn {
|
||||
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
|
||||
}
|
||||
if next.Turn != You {
|
||||
t.Fatalf("the turn should still be yours: %d", next.Turn)
|
||||
}
|
||||
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
||||
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
||||
}
|
||||
if got := next.Playable(); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
||||
}
|
||||
// You may not play the *other* card instead — drawing would otherwise be a
|
||||
// free look with no cost.
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||
t.Fatalf("only the drawn card may be played, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||
t.Fatalf("you can't draw twice, got %v", err)
|
||||
}
|
||||
after, _, err := ApplyMove(next, Move{Kind: MovePass})
|
||||
if err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
if after.Phase != PhasePlay || after.Turn != You {
|
||||
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
|
||||
}
|
||||
if len(after.Hands[You]) != 2 {
|
||||
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhasePlay {
|
||||
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvPass) {
|
||||
t.Errorf("the turn passed, and the table should be told: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassOnlyAfterADraw(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass {
|
||||
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
|
||||
// goes on the pile. Every seat can only pass, forever.
|
||||
func dead(hands [][]Card) State {
|
||||
s := rig(hands, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// The game has to end here. It used to not: the stuck guard counted how many
|
||||
// bots had passed in a row and asked for more of them than there are seats, so
|
||||
// it never fired once, and a dead table just handed the turn round and round.
|
||||
// That is a game the player cannot finish — and a game they cannot finish is
|
||||
// chips they cannot cash out, because the cage won't let them leave a live hand.
|
||||
func TestDeadTableEnds(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
|
||||
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Phase != PhaseDone {
|
||||
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
|
||||
}
|
||||
if next.Outcome != OutcomeStuck {
|
||||
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
|
||||
}
|
||||
if next.Payout != 0 {
|
||||
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
|
||||
}
|
||||
if !hasKind(evs, EvSettle) {
|
||||
t.Errorf("the table has to be told it's over: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// And the shortest hand takes it, which is the one way a stuck table still pays.
|
||||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon {
|
||||
t.Fatalf("one card against two is a win: %q", next.Outcome)
|
||||
}
|
||||
if next.Payout != s.Pays() {
|
||||
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
// An empty deck, and a discard with something under the top card to become one.
|
||||
// The buried wild went down as green; it has to come back as a wild.
|
||||
s.Deck = nil
|
||||
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw on an empty deck: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvReshuffle) {
|
||||
t.Fatalf("want a reshuffle: %+v", evs)
|
||||
}
|
||||
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
|
||||
t.Errorf("the card in play stays on the pile: %v", next.Discard)
|
||||
}
|
||||
for _, c := range next.Deck {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild went back into the deck stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
for _, h := range next.Hands {
|
||||
for _, c := range h {
|
||||
if c.Value == WildCard && c.Color != Wild {
|
||||
t.Errorf("a wild was dealt out stamped %v", c.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the money ------------------------------------------------------------
|
||||
|
||||
// The rule every game in this casino has had to be taught: the number the felt
|
||||
// quotes and the number the settle lands on are one function, not two.
|
||||
func TestQuoteIsThePayout(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = tier
|
||||
s.Hands = make([][]Card, tier.Bots+1)
|
||||
s.Hands[You] = []Card{{Red, Three}}
|
||||
for i := 1; i <= tier.Bots; i++ {
|
||||
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
|
||||
}
|
||||
quoted := s.Pays()
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||
if err != nil {
|
||||
t.Fatalf("%s: go out: %v", tier.Slug, err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon {
|
||||
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
|
||||
}
|
||||
if next.Payout != quoted {
|
||||
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
|
||||
}
|
||||
if next.Net() != quoted-s.Bet {
|
||||
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rake comes out of the winnings, never the stake.
|
||||
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
|
||||
s.Bet = 100
|
||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if next.Payout != 214 {
|
||||
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
|
||||
}
|
||||
if next.Rake != 6 {
|
||||
t.Errorf("rake %d, want 6", next.Rake)
|
||||
}
|
||||
if next.Net() != 114 {
|
||||
t.Errorf("net %d, want 114", next.Net())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
||||
// The bot holds one card that plays on the pile, so it goes out the moment the
|
||||
// turn reaches it.
|
||||
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeLost {
|
||||
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
|
||||
}
|
||||
if next.Payout != 0 || next.Rake != 0 {
|
||||
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
|
||||
}
|
||||
if next.Net() != -s.Bet {
|
||||
t.Errorf("a loss costs the stake and no more: %d", next.Net())
|
||||
}
|
||||
last := evs[len(evs)-1]
|
||||
if last.Kind != EvSettle || last.Seat != 1 {
|
||||
t.Errorf("the settle should name the winner: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMoveAfterItIsOver(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
|
||||
t.Fatalf("a finished game takes no more moves, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadBet(t *testing.T) {
|
||||
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
|
||||
t.Fatalf("want ErrBadBet, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the whole game -------------------------------------------------------
|
||||
|
||||
// playOut plays a game to its end with a simple strategy: play the first legal
|
||||
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
|
||||
func playOut(t *testing.T, s State, maxTurns int) State {
|
||||
t.Helper()
|
||||
for turn := 0; s.Phase != PhaseDone; turn++ {
|
||||
if turn > maxTurns {
|
||||
t.Fatalf("the game never ended in %d turns", maxTurns)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
||||
}
|
||||
|
||||
var m Move
|
||||
if p := s.Playable(); len(p) > 0 {
|
||||
m = Move{Kind: MovePlay, Index: p[0]}
|
||||
if s.Hands[You][p[0]].IsWild() {
|
||||
m.Color = Green
|
||||
}
|
||||
} else if s.Phase == PhaseDrawn {
|
||||
m = Move{Kind: MovePass}
|
||||
} else {
|
||||
m = Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
if err != nil {
|
||||
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
||||
}
|
||||
if len(evs) == 0 {
|
||||
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
|
||||
}
|
||||
if got := total(census(next)); got != 108 {
|
||||
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
|
||||
}
|
||||
for c, n := range census(next) {
|
||||
if want := deckCount(c); n != want {
|
||||
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
||||
}
|
||||
}
|
||||
// No event ever names a bot's card. That is the hole card of this game, and
|
||||
// it is most of the deck.
|
||||
for _, e := range evs {
|
||||
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
|
||||
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
|
||||
}
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deckCount is how many of a given card a 108 deck holds.
|
||||
func deckCount(c Card) int {
|
||||
switch {
|
||||
case c.Color == Wild:
|
||||
return 4
|
||||
case c.Value == Zero:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// A hundred games, played out, with the invariants checked at every step. This
|
||||
// is the test that would have caught a deck that leaks cards through the
|
||||
// reshuffle, a turn the bots don't hand back, or a game that can't end.
|
||||
func TestGamesPlayOut(t *testing.T) {
|
||||
wins, losses, stuck := 0, 0, 0
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
tier := Tiers[seed%3]
|
||||
end := playOut(t, deal(t, tier, 100, seed), 500)
|
||||
switch end.Outcome {
|
||||
case OutcomeWon:
|
||||
wins++
|
||||
if end.Payout != end.Pays() {
|
||||
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
|
||||
}
|
||||
case OutcomeLost:
|
||||
losses++
|
||||
case OutcomeStuck:
|
||||
stuck++
|
||||
default:
|
||||
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
|
||||
}
|
||||
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
|
||||
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
||||
}
|
||||
}
|
||||
// Playing the first legal card is a poor strategy against bots that hold their
|
||||
// +4s back, so this is not a fairness assertion — it's a check that both
|
||||
// outcomes actually happen. A table that never pays is a bug in the bots.
|
||||
if wins == 0 || losses == 0 {
|
||||
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
|
||||
}
|
||||
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
|
||||
}
|
||||
|
||||
// winnerSeat is the seat the settle event named — only used by the tests.
|
||||
func (s State) winnerSeat() int {
|
||||
best := 0
|
||||
for i := range s.Hands {
|
||||
if len(s.Hands[i]) < len(s.Hands[best]) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// The same seed deals the same game and the bots make the same choices — which
|
||||
// is what lets a disputed game be replayed exactly as it fell.
|
||||
func TestReplaysFromTheSeed(t *testing.T) {
|
||||
a := playOut(t, deal(t, full(), 250, 42), 500)
|
||||
b := playOut(t, deal(t, full(), 250, 42), 500)
|
||||
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
if string(ja) != string(jb) {
|
||||
t.Fatal("the same seed played the same way gave two different games")
|
||||
}
|
||||
if a.Outcome == "" {
|
||||
t.Fatal("the replay didn't finish")
|
||||
}
|
||||
}
|
||||
|
||||
// A game in progress survives a redeploy: it is a plain value, so it round-trips
|
||||
// through the JSON it is stored as.
|
||||
func TestStateSurvivesStorage(t *testing.T) {
|
||||
s := deal(t, table(), 100, 9)
|
||||
s, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back State
|
||||
if err := json.Unmarshal(blob, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
again, _ := json.Marshal(back)
|
||||
if string(again) != string(blob) {
|
||||
t.Fatal("a stored game came back different")
|
||||
}
|
||||
// And it plays on from there.
|
||||
playOut(t, back, 500)
|
||||
}
|
||||
|
||||
// A move the engine refuses leaves the caller's state exactly as it was — no
|
||||
// card half-played, no turn half-passed.
|
||||
func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
before, _ := json.Marshal(s)
|
||||
|
||||
for _, m := range []Move{
|
||||
{Kind: MovePlay, Index: 0}, // doesn't match
|
||||
{Kind: MovePlay, Index: 1}, // wild with no colour
|
||||
{Kind: MovePlay, Index: 9}, // no such card
|
||||
{Kind: MovePass}, // nothing drawn
|
||||
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
||||
} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
t.Fatalf("%+v should have been refused", m)
|
||||
}
|
||||
}
|
||||
after, _ := json.Marshal(s)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("a refused move touched the state")
|
||||
}
|
||||
}
|
||||
|
||||
// The bots choose. Two different seeds should not play the same bot game, or the
|
||||
// bot is a lookup table you can memorise.
|
||||
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||
same := 0
|
||||
for seed := uint64(0); seed < 20; seed++ {
|
||||
a := playOut(t, deal(t, duel(), 100, seed), 500)
|
||||
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
|
||||
if len(a.Discard) == len(b.Discard) {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 20 {
|
||||
t.Fatal("every seed played out to the same length — the bots aren't choosing")
|
||||
}
|
||||
}
|
||||
|
||||
// botPick holds its +4 back while it's comfortable, and reaches for it when
|
||||
// somebody is about to go out.
|
||||
func TestBotSavesTheDrawFour(t *testing.T) {
|
||||
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
||||
top, color := Card{Red, Nine}, Red
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
|
||||
held := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
|
||||
held++
|
||||
}
|
||||
}
|
||||
if held < 30 {
|
||||
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
|
||||
}
|
||||
|
||||
reached := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
|
||||
reached++
|
||||
}
|
||||
}
|
||||
if reached < 30 {
|
||||
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPicksItsBestColor(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(3, 4))
|
||||
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
|
||||
if got := botColor(hand, rng); got != Blue {
|
||||
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
||||
}
|
||||
// A hand of nothing but wilds still has to name something.
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
||||
t.Fatalf("botColor named %v, which is not a colour", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotHasNothingToPlay(t *testing.T) {
|
||||
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
|
||||
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func hasKind(evs []Event, kind string) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
plainParts = append(plainParts, s.Lede)
|
||||
}
|
||||
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
|
||||
plainParts = append(plainParts, tag)
|
||||
}
|
||||
plain = strings.Join(plainParts, "\n")
|
||||
|
||||
// HTML body
|
||||
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
|
||||
if s.Lede != "" {
|
||||
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
|
||||
}
|
||||
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
|
||||
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
|
||||
htmlParts = append(htmlParts, tag)
|
||||
}
|
||||
htmlBody = strings.Join(htmlParts, "<br/>")
|
||||
|
||||
return plain, htmlBody
|
||||
}
|
||||
|
||||
// formatSourceTag builds the source + platform tags line.
|
||||
// formatSourceTag builds the source + platform tags line. An empty source is
|
||||
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
|
||||
// an empty tag would read as him signing his own name.
|
||||
func formatSourceTag(source string, platforms []string, isHTML bool) string {
|
||||
var parts []string
|
||||
if source != "" {
|
||||
parts = append(parts, strings.ToLower(source))
|
||||
}
|
||||
parts = append(parts, platforms...)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, p := range parts {
|
||||
if isHTML {
|
||||
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
|
||||
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
|
||||
} else {
|
||||
parts[i] = fmt.Sprintf("`%s`", p)
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
|
||||
for _, p := range platforms {
|
||||
parts = append(parts, fmt.Sprintf("`%s`", p))
|
||||
}
|
||||
return strings.Join(parts, " \u00b7 ")
|
||||
}
|
||||
|
||||
180
internal/opentdb/opentdb.go
Normal file
180
internal/opentdb/opentdb.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package opentdb fills the casino's trivia bank from the Open Trivia Database.
|
||||
//
|
||||
// The questions are *prefetched* into a local table, not fetched per question,
|
||||
// and that is a deliberate call rather than an optimisation. A trivia ladder
|
||||
// asks a question every fifteen seconds with money on the clock: a per-question
|
||||
// fetch would put somebody else's latency, rate limit and downtime inside a
|
||||
// timed round the player is being scored against. Pull the bank in the
|
||||
// background, and a round becomes a local read that either works or doesn't.
|
||||
//
|
||||
// OpenTDB allows one request every five seconds per IP and caps a batch at 50,
|
||||
// so the refill is a slow, polite drip, run in the background and never in the
|
||||
// path of anything a player is waiting for.
|
||||
package opentdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/safehttp"
|
||||
)
|
||||
|
||||
// endpoint is the API. It is the only host this package ever talks to, and it
|
||||
// goes through safehttp like every other outbound fetch in Pete.
|
||||
const endpoint = "https://opentdb.com/api.php"
|
||||
|
||||
// Batch is the most OpenTDB will hand over in one request.
|
||||
const Batch = 50
|
||||
|
||||
// Politeness is the gap the API asks for between requests. Going faster earns a
|
||||
// response_code 5 and nothing else.
|
||||
const Politeness = 6 * time.Second
|
||||
|
||||
// fetchTimeout bounds a single request. The refill runs in the background, so a
|
||||
// slow answer costs nothing but its own goroutine — but it must still end.
|
||||
const fetchTimeout = 20 * time.Second
|
||||
|
||||
// maxBody caps what we will read from the API, hostile or merely broken.
|
||||
const maxBody = 1 << 20
|
||||
|
||||
// apiResponse is OpenTDB's envelope. ResponseCode is the part that matters:
|
||||
// zero is the only one that means "here are your questions".
|
||||
type apiResponse struct {
|
||||
ResponseCode int `json:"response_code"`
|
||||
Results []struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
Question string `json:"question"`
|
||||
Correct string `json:"correct_answer"`
|
||||
Incorrect []string `json:"incorrect_answers"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// responseErr turns a non-zero code into something a log line can explain.
|
||||
func responseErr(code int) error {
|
||||
switch code {
|
||||
case 1:
|
||||
return fmt.Errorf("opentdb: no results for that query")
|
||||
case 2:
|
||||
return fmt.Errorf("opentdb: the query was invalid")
|
||||
case 3, 4:
|
||||
return fmt.Errorf("opentdb: session token expired or exhausted")
|
||||
case 5:
|
||||
return fmt.Errorf("opentdb: rate limited — slow down")
|
||||
default:
|
||||
return fmt.Errorf("opentdb: response code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Client fetches questions.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: safehttp.NewClient(fetchTimeout)}
|
||||
}
|
||||
|
||||
// Fetch pulls up to n multiple-choice questions of one difficulty.
|
||||
//
|
||||
// Only "multiple" questions are asked for: the ladder is four buttons, and a
|
||||
// true/false question on the same felt would be a coin flip dressed up as a
|
||||
// question — and a coin flip the player is being paid a difficulty multiple for.
|
||||
func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia.Question, error) {
|
||||
if n <= 0 || n > Batch {
|
||||
n = Batch
|
||||
}
|
||||
q := url.Values{
|
||||
"amount": {fmt.Sprint(n)},
|
||||
"difficulty": {difficulty},
|
||||
"type": {"multiple"},
|
||||
}
|
||||
raw := endpoint + "?" + q.Encode()
|
||||
if err := safehttp.ValidateURL(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "pete-games/1.0 (+https://games.parodia.dev)")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("opentdb: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(safehttp.LimitedBody(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out apiResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("opentdb: %w", err)
|
||||
}
|
||||
if out.ResponseCode != 0 {
|
||||
return nil, responseErr(out.ResponseCode)
|
||||
}
|
||||
|
||||
qs := make([]trivia.Question, 0, len(out.Results))
|
||||
for _, r := range out.Results {
|
||||
// The API hands back HTML entities ("Who wrote "Dune"?"), which
|
||||
// would otherwise be drawn literally onto a button.
|
||||
text := clean(r.Question)
|
||||
correct := clean(r.Correct)
|
||||
if text == "" || correct == "" || len(r.Incorrect) != 3 {
|
||||
continue // a malformed question is one we simply don't take
|
||||
}
|
||||
|
||||
// Correct: 0 here is a convention, not a tell. The engine reshuffles every
|
||||
// question against the game's own seed as it builds the ladder, so where
|
||||
// the right answer sits in the bank never reaches a player.
|
||||
answers := make([]string, 0, 4)
|
||||
answers = append(answers, correct)
|
||||
dupe := false
|
||||
for _, w := range r.Incorrect {
|
||||
a := clean(w)
|
||||
// A wrong answer that reads the same as the right one — usually two
|
||||
// spellings that collapse once the entities are decoded — is a question
|
||||
// with two identical buttons on it, and the shuffle can only call one of
|
||||
// them correct. A player who clicked the right words and was told they
|
||||
// were wrong has lost the whole ladder to our typography. Drop it.
|
||||
if a == "" || a == correct {
|
||||
dupe = true
|
||||
break
|
||||
}
|
||||
answers = append(answers, a)
|
||||
}
|
||||
if dupe || len(answers) != 4 {
|
||||
continue
|
||||
}
|
||||
qs = append(qs, trivia.Question{
|
||||
Category: clean(r.Category),
|
||||
Text: text,
|
||||
Answers: answers,
|
||||
Correct: 0,
|
||||
})
|
||||
}
|
||||
return qs, nil
|
||||
}
|
||||
|
||||
// clean turns an API string into something you can put on a button: entities
|
||||
// decoded, whitespace tidied.
|
||||
func clean(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
601
internal/storage/games.go
Normal file
601
internal/storage/games.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The chip ledger and the euro/chip border.
|
||||
//
|
||||
// Chips are euros that have crossed into the casino. They are 1:1 with euros and
|
||||
// they are not a second wallet: every chip that exists came from a euro gogobee
|
||||
// debited, and every chip destroyed becomes a euro gogobee credits back. Pete
|
||||
// never writes a euro balance. The border is crossed only by a game_escrow row,
|
||||
// whose guid is the idempotency key gogobee hands to DebitIdem/CreditIdem — so a
|
||||
// claim whose acknowledgement is lost on the wire can be retried without the
|
||||
// player paying for it twice.
|
||||
//
|
||||
// The whole reason for the border is latency. gogobee has no inbound API and is
|
||||
// not getting one, so it polls; a bet that round-tripped through a poll loop
|
||||
// would take seconds to be dealt. Instead the poll loop runs twice per *session*
|
||||
// — buy in, cash out — and every hand in between plays against chips held here,
|
||||
// at full speed, with no economy call in the hot path.
|
||||
|
||||
// MaxChipsOnTable caps how many chips a player can hold at once. A buy-in that
|
||||
// would push them over is refused before it ever reaches gogobee.
|
||||
//
|
||||
// This is the inflation brake. A web casino runs orders of magnitude more hands
|
||||
// per hour than a Matrix-paced one ever did, so whatever the house edge is, it
|
||||
// compounds far faster in both directions. The cap bounds the worst case for a
|
||||
// single sitting; the rake (see the blackjack engine) bleeds the rest back out.
|
||||
const MaxChipsOnTable int64 = 10_000
|
||||
|
||||
// EscrowStaleAfter is how long a claimed-but-unsettled escrow row waits before
|
||||
// the poll endpoint offers it again. gogobee can die between claiming a row and
|
||||
// pushing its result; without a re-offer, the player's money sits in limbo
|
||||
// forever. Re-claiming is safe precisely because the guid makes it idempotent.
|
||||
const EscrowStaleAfter = 90 * time.Second
|
||||
|
||||
// SessionIdleAfter is when the reaper decides a player has walked away and cashes
|
||||
// their chips back to euros on their behalf. Chips in an abandoned session are
|
||||
// euros in limbo, and limbo is not a state a player's money should be in.
|
||||
const SessionIdleAfter = 30 * time.Minute
|
||||
|
||||
// Escrow kinds and states. These strings cross the wire to gogobee, so they are
|
||||
// part of the contract — see §4 of pete_games_plan.md.
|
||||
const (
|
||||
KindBuyIn = "buyin"
|
||||
KindCashOut = "cashout"
|
||||
|
||||
EscrowRequested = "requested" // the player asked; gogobee hasn't seen it yet
|
||||
EscrowClaimed = "claimed" // gogobee has it and is moving the euros
|
||||
EscrowFunded = "funded" // buy-in landed; the chips are spendable
|
||||
EscrowRejected = "rejected" // buy-in refused; no chips, no euros moved
|
||||
EscrowSettled = "settled" // cash-out landed; chips destroyed, euros credited
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientChips = errors.New("games: not enough chips")
|
||||
ErrOverTableCap = errors.New("games: that would put more than the cap on the table")
|
||||
ErrBadAmount = errors.New("games: amount must be positive")
|
||||
ErrNoSuchEscrow = errors.New("games: no such escrow row")
|
||||
)
|
||||
|
||||
// Escrow is one crossing of the euro/chip border.
|
||||
type Escrow struct {
|
||||
GUID string `json:"guid"`
|
||||
MatrixUser string `json:"matrix_user"`
|
||||
Kind string `json:"kind"`
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChipStack is what a player has on the table right now.
|
||||
type ChipStack struct {
|
||||
Chips int64 // spendable
|
||||
// Pending is chips asked for but not yet funded — a buy-in gogobee hasn't
|
||||
// claimed or settled. Shown as "buying chips…", never spendable.
|
||||
Pending int64
|
||||
EuroBalance float64 // advisory, from the last gogobee push; may be minutes stale
|
||||
LastPlayed int64
|
||||
}
|
||||
|
||||
// newGUID mints an escrow id. It's the idempotency key for a real money move, so
|
||||
// it comes from crypto/rand rather than anything a caller could collide with.
|
||||
func newGUID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("games: mint guid: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Chips reports a player's stack. A player who has never played has no row and
|
||||
// reads as an empty stack rather than an error.
|
||||
func Chips(user string) (ChipStack, error) {
|
||||
var st ChipStack
|
||||
var euro sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT chips, euro_balance, last_played FROM game_chips WHERE matrix_user = ?`, user,
|
||||
).Scan(&st.Chips, &euro, &st.LastPlayed)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ChipStack{}, fmt.Errorf("games: read chips: %w", err)
|
||||
}
|
||||
st.EuroBalance = euro.Float64
|
||||
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(amount), 0) FROM game_escrow
|
||||
WHERE matrix_user = ? AND kind = ? AND state IN (?, ?)`,
|
||||
user, KindBuyIn, EscrowRequested, EscrowClaimed,
|
||||
).Scan(&st.Pending); err != nil {
|
||||
return ChipStack{}, fmt.Errorf("games: read pending buy-ins: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RequestBuyIn opens a buy-in: the player wants `amount` euros turned into chips.
|
||||
// No chips exist yet — they appear only when gogobee confirms it took the euros.
|
||||
// The table cap is enforced here, against chips already held *plus* buy-ins still
|
||||
// in flight, so a player can't clear the cap by firing several at once.
|
||||
func RequestBuyIn(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
if st.Chips+st.Pending+amount > MaxChipsOnTable {
|
||||
return Escrow{}, ErrOverTableCap
|
||||
}
|
||||
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindBuyIn, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request buy-in: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindBuyIn, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// RequestCashOut opens a cash-out: chips are destroyed *now*, and the matching
|
||||
// euros arrive when gogobee claims the row.
|
||||
//
|
||||
// Destroying them up front is what keeps the invariant true. If the chips lingered
|
||||
// until gogobee confirmed, a player could bet them while the cash-out was in
|
||||
// flight and the same euro would exist on both sides of the border. If the credit
|
||||
// somehow fails, RefundCashOut puts the chips back.
|
||||
func RequestCashOut(user string, amount int64) (Escrow, error) {
|
||||
if amount <= 0 {
|
||||
return Escrow{}, ErrBadAmount
|
||||
}
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin cash-out: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
// Conditional update: the chips leave only if they're actually there.
|
||||
res, err := tx.Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: debit chips: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return Escrow{}, ErrInsufficientChips
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_escrow (guid, matrix_user, kind, amount, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, user, KindCashOut, amount, EscrowRequested, now, now,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: request cash-out: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit cash-out: %w", err)
|
||||
}
|
||||
return Escrow{GUID: guid, MatrixUser: user, Kind: KindCashOut, Amount: amount, State: EscrowRequested, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// PendingEscrow is what gogobee's poll loop reads: everything waiting to be moved.
|
||||
//
|
||||
// It returns rows nobody has claimed, *and* rows claimed long enough ago that we
|
||||
// have to assume gogobee died holding them. Re-offering a claimed row is safe
|
||||
// because the guid is idempotent end to end: if gogobee already moved the euros,
|
||||
// the retry is a no-op that reports the same answer.
|
||||
func PendingEscrow(limit int) ([]Escrow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, matrix_user, kind, amount, state, created_at
|
||||
FROM game_escrow
|
||||
WHERE state = ?
|
||||
OR (state = ? AND COALESCE(claimed_at, 0) < ?)
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
EscrowRequested, EscrowClaimed, stale, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("games: pending escrow: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Escrow
|
||||
for rows.Next() {
|
||||
var e Escrow
|
||||
if err := rows.Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("games: scan escrow: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClaimEscrow marks a row as taken by gogobee. Claiming is idempotent and is not
|
||||
// a lock: a row already claimed can be claimed again (that's how a stale re-offer
|
||||
// works), but a row already *finished* cannot be, which is what stops a settled
|
||||
// cash-out from being paid a second time.
|
||||
func ClaimEscrow(guid string) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_escrow SET state = ?, claimed_at = ?, updated_at = ?
|
||||
WHERE guid = ? AND state IN (?, ?)`,
|
||||
EscrowClaimed, now, now, guid, EscrowRequested, EscrowClaimed,
|
||||
)
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: claim escrow: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// Either it doesn't exist or it's already finished. Tell the caller which.
|
||||
e, err := EscrowByGUID(guid)
|
||||
if err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// EscrowByGUID reads one row.
|
||||
func EscrowByGUID(guid string) (Escrow, error) {
|
||||
var e Escrow
|
||||
var reason sql.NullString
|
||||
var bal sql.NullFloat64
|
||||
err := Get().QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state, reason, balance_after, created_at
|
||||
FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &e.State, &reason, &bal, &e.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
}
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: read escrow: %w", err)
|
||||
}
|
||||
e.Reason, e.BalanceAfter = reason.String, bal.Float64
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// SettleEscrow applies gogobee's verdict on a claimed row, and is the only place
|
||||
// chips are created or finally destroyed.
|
||||
//
|
||||
// buy-in, ok -> chips appear (funded)
|
||||
// buy-in, !ok -> nothing happens, nothing moved (rejected)
|
||||
// cash-out, ok -> chips stay destroyed, euros paid (settled)
|
||||
// cash-out, !ok -> chips come back (funded — the player never lost them)
|
||||
//
|
||||
// It is idempotent: gogobee's push queue retries, so the same verdict can arrive
|
||||
// more than once and only the first one may move chips. A row that has already
|
||||
// reached a terminal state is a no-op, not an error.
|
||||
func SettleEscrow(guid string, ok bool, reason string, balanceAfter float64) (Escrow, error) {
|
||||
now := nowUnix()
|
||||
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: begin settle: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
var e Escrow
|
||||
var st string
|
||||
if err := tx.QueryRow(
|
||||
`SELECT guid, matrix_user, kind, amount, state FROM game_escrow WHERE guid = ?`, guid,
|
||||
).Scan(&e.GUID, &e.MatrixUser, &e.Kind, &e.Amount, &st); errors.Is(err, sql.ErrNoRows) {
|
||||
return Escrow{}, ErrNoSuchEscrow
|
||||
} else if err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle lookup: %w", err)
|
||||
}
|
||||
|
||||
// Terminal already — a retried push. Report what we decided the first time.
|
||||
if st == EscrowFunded || st == EscrowRejected || st == EscrowSettled {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
final := EscrowFunded
|
||||
switch {
|
||||
case e.Kind == KindBuyIn && ok:
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
case e.Kind == KindBuyIn && !ok:
|
||||
final = EscrowRejected // gogobee took nothing, so we create nothing
|
||||
case e.Kind == KindCashOut && ok:
|
||||
final = EscrowSettled // the chips were destroyed when the row was opened
|
||||
case e.Kind == KindCashOut && !ok:
|
||||
// gogobee couldn't pay. The chips were already destroyed on our side, so
|
||||
// give them back rather than vanishing the player's money.
|
||||
if err := addChips(tx, e.MatrixUser, e.Amount, now); err != nil {
|
||||
return Escrow{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_escrow SET state = ?, reason = ?, balance_after = ?, updated_at = ?
|
||||
WHERE guid = ?`,
|
||||
final, reason, balanceAfter, now, guid,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: settle update: %w", err)
|
||||
}
|
||||
|
||||
// The euro balance gogobee just reported is the freshest one we'll get.
|
||||
// Advisory only — we display it, we never decide anything with it.
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE game_chips SET euro_balance = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
balanceAfter, now, e.MatrixUser,
|
||||
); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: cache euro balance: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Escrow{}, fmt.Errorf("games: commit settle: %w", err)
|
||||
}
|
||||
return EscrowByGUID(guid)
|
||||
}
|
||||
|
||||
// addChips credits a stack inside an open transaction, creating the row if the
|
||||
// player has never held chips before.
|
||||
func addChips(tx *sql.Tx, user string, amount int64, now int64) error {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET chips = chips + excluded.chips, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: credit chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stake takes chips off a player's stack to put them at risk on a hand. It is the
|
||||
// conditional-update kind of debit: the chips leave in the same statement that
|
||||
// checks they're there, so two hands opened at once can't spend the same chip.
|
||||
func Stake(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrBadAmount
|
||||
}
|
||||
now := nowUnix()
|
||||
res, err := Get().Exec(
|
||||
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
|
||||
WHERE matrix_user = ? AND chips >= ?`,
|
||||
amount, now, now, user, amount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: stake: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInsufficientChips
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Award returns chips to a player when a hand settles: stake plus winnings, net
|
||||
// of rake, exactly as the engine computed it. A losing hand awards nothing and
|
||||
// should not call this.
|
||||
func Award(user string, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_chips (matrix_user, chips, last_played, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
chips = chips + excluded.chips, last_played = excluded.last_played, updated_at = excluded.updated_at`,
|
||||
user, amount, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: award chips: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hand is one settled hand, as the audit log keeps it.
|
||||
type Hand struct {
|
||||
MatrixUser string
|
||||
Game string
|
||||
Bet int64
|
||||
Payout int64
|
||||
Rake int64
|
||||
Outcome string
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
}
|
||||
|
||||
// RecordHand writes a finished hand to the audit trail. The seeds are the point:
|
||||
// with them, any hand in the log can be dealt again exactly as it fell, which is
|
||||
// how a dispute gets answered with a fact instead of an apology.
|
||||
func RecordHand(h Hand) error {
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_hands (matrix_user, game, bet, payout, rake, outcome, seed1, seed2, played_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
h.MatrixUser, h.Game, h.Bet, h.Payout, h.Rake, h.Outcome,
|
||||
int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: record hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdleStacks lists players holding chips who stopped playing a while ago. The
|
||||
// reaper cashes these out on their behalf: chips in an abandoned session are
|
||||
// euros in limbo, and they should be back in the player's balance where they can
|
||||
// see them.
|
||||
func IdleStacks(idleFor time.Duration) ([]ChipStack, []string, error) {
|
||||
cutoff := nowUnix() - int64(idleFor.Seconds())
|
||||
rows, err := Get().Query(
|
||||
`SELECT matrix_user, chips, last_played FROM game_chips
|
||||
WHERE chips > 0 AND last_played > 0 AND last_played < ?`, cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("games: idle stacks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stacks []ChipStack
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var st ChipStack
|
||||
var user string
|
||||
if err := rows.Scan(&user, &st.Chips, &st.LastPlayed); err != nil {
|
||||
return nil, nil, fmt.Errorf("games: scan idle stack: %w", err)
|
||||
}
|
||||
stacks = append(stacks, st)
|
||||
users = append(users, user)
|
||||
}
|
||||
return stacks, users, rows.Err()
|
||||
}
|
||||
|
||||
// ReapIdleSessions cashes out everyone who walked away, and reports how many it
|
||||
// sent home. Safe to run on a timer: a player who comes back simply buys in again,
|
||||
// and a cash-out that's already in flight can't be opened twice because the chips
|
||||
// are gone from the stack the moment the first one is.
|
||||
func ReapIdleSessions(idleFor time.Duration) (int, error) {
|
||||
stacks, users, err := IdleStacks(idleFor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reaped := 0
|
||||
for i, user := range users {
|
||||
if _, err := RequestCashOut(user, stacks[i].Chips); err != nil {
|
||||
// One player's stack failing to reap shouldn't strand everyone else's.
|
||||
if !errors.Is(err, ErrInsufficientChips) {
|
||||
return reaped, fmt.Errorf("games: reap %s: %w", user, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
reaped++
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
|
||||
// Touch marks a player as active, so the reaper leaves them alone. Called on any
|
||||
// deliberate action at a table — not on a page load, or an open tab would keep a
|
||||
// walked-away player's chips hostage forever.
|
||||
func Touch(user string) {
|
||||
exec("games: touch session",
|
||||
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
|
||||
nowUnix(), nowUnix(), user)
|
||||
}
|
||||
|
||||
// ---- the hand in progress -------------------------------------------------
|
||||
|
||||
var (
|
||||
// ErrNoLiveHand means the player isn't in a hand right now.
|
||||
ErrNoLiveHand = errors.New("games: no hand in progress")
|
||||
// ErrHandInProgress means they already are, and may not be dealt another.
|
||||
ErrHandInProgress = errors.New("games: already in a hand")
|
||||
)
|
||||
|
||||
// LiveHand is a hand a player is in the middle of. State is the engine's own
|
||||
// State, serialized whole — the shoe is in there, which is exactly why this row
|
||||
// never leaves the server.
|
||||
type LiveHand struct {
|
||||
Game string
|
||||
State []byte
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
}
|
||||
|
||||
// StartLiveHand seats a *new* hand, and refuses if the player is already in one.
|
||||
// The plain INSERT is the point: it is the primary key, not a prior read, that
|
||||
// decides. Two Deal clicks racing each other would otherwise both see an empty
|
||||
// felt, both take a stake, and the second would overwrite the first — taking the
|
||||
// player's chips for a hand that no longer exists anywhere.
|
||||
func StartLiveHand(user string, h LiveHand) error {
|
||||
res, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO NOTHING`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), nowUnix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("games: start live hand: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrHandInProgress
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLiveHand stores the hand a player is in, replacing any earlier one. The
|
||||
// player's stake has already left their stack by the time this is called, so
|
||||
// the write is what makes the hand recoverable if Pete restarts mid-deal.
|
||||
func SaveLiveHand(user string, h LiveHand) error {
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(matrix_user) DO UPDATE SET
|
||||
game = excluded.game, state = excluded.state,
|
||||
seed1 = excluded.seed1, seed2 = excluded.seed2, updated_at = excluded.updated_at`,
|
||||
user, h.Game, string(h.State), int64(h.Seed1), int64(h.Seed2), now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("games: save live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLiveHand returns the hand a player is in, or ErrNoLiveHand.
|
||||
func LoadLiveHand(user string) (LiveHand, error) {
|
||||
var h LiveHand
|
||||
var state string
|
||||
var s1, s2 int64
|
||||
err := Get().QueryRow(
|
||||
`SELECT game, state, seed1, seed2 FROM game_live_hands WHERE matrix_user = ?`, user,
|
||||
).Scan(&h.Game, &state, &s1, &s2)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LiveHand{}, ErrNoLiveHand
|
||||
}
|
||||
if err != nil {
|
||||
return LiveHand{}, fmt.Errorf("games: load live hand: %w", err)
|
||||
}
|
||||
h.State, h.Seed1, h.Seed2 = []byte(state), uint64(s1), uint64(s2)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// ClearLiveHand ends a hand. Called when it settles — the audit log in
|
||||
// game_hands is what survives it.
|
||||
func ClearLiveHand(user string) error {
|
||||
if _, err := Get().Exec(`DELETE FROM game_live_hands WHERE matrix_user = ?`, user); err != nil {
|
||||
return fmt.Errorf("games: clear live hand: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HouseTake is the total rake collected since a given time — the number that
|
||||
// answers "is this economy inflating".
|
||||
func HouseTake(since int64) (int64, error) {
|
||||
var total int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COALESCE(SUM(rake), 0) FROM game_hands WHERE played_at >= ?`, since,
|
||||
).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("games: house take: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
494
internal/storage/games_test.go
Normal file
494
internal/storage/games_test.go
Normal file
@@ -0,0 +1,494 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const player = "@reala:parodia.dev"
|
||||
|
||||
// fund runs a buy-in all the way through the happy path, so a test that needs
|
||||
// chips on the table can just say so.
|
||||
func fund(t *testing.T, user string, amount int64) {
|
||||
t.Helper()
|
||||
e, err := RequestBuyIn(user, amount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsOf(t *testing.T, user string) int64 {
|
||||
t.Helper()
|
||||
st, err := Chips(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.State != EscrowRequested {
|
||||
t.Fatalf("state = %q, want %q", e.State, EscrowRequested)
|
||||
}
|
||||
|
||||
// Requested is not funded. Nothing is spendable yet — gogobee hasn't taken
|
||||
// the euros, so creating chips here would mint money out of nothing.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 0 {
|
||||
t.Fatalf("chips = %d before settlement, want 0", st.Chips)
|
||||
}
|
||||
if st.Pending != 500 {
|
||||
t.Fatalf("pending = %d, want 500", st.Pending)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err = Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Chips != 500 {
|
||||
t.Fatalf("chips = %d after funding, want 500", st.Chips)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d after funding, want 0", st.Pending)
|
||||
}
|
||||
if st.EuroBalance != 4500 {
|
||||
t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_RejectedCreatesNoChips(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowRejected {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowRejected)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after a rejected buy-in, want 0", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The push queue retries. A verdict that lands twice must only move chips once.
|
||||
func TestSettle_IsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatalf("settle %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d after three identical pushes, want 500", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A late, contradictory push must not overturn a settled row — otherwise a
|
||||
// delayed "rejected" could confiscate chips the player already won hands with.
|
||||
func TestSettle_TerminalStateWins(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
e, err := RequestBuyIn(player, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowFunded {
|
||||
t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 500 {
|
||||
t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_ChipsLeaveImmediately(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The chips are gone the moment the row opens. If they lingered until gogobee
|
||||
// confirmed, the player could bet them while the euros were also in flight —
|
||||
// the same euro on both sides of the border.
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d immediately after cash-out, want 600", c)
|
||||
}
|
||||
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := SettleEscrow(e.GUID, true, "", 4400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 600 {
|
||||
t.Fatalf("chips = %d after settlement, want 600", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
e, err := RequestCashOut(player, 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// gogobee couldn't pay. The chips were already destroyed here, so they have to
|
||||
// come back — the player's money cannot simply evaporate at the border.
|
||||
if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 1000 {
|
||||
t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashOut_CannotExceedTheStack(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d after a refused cash-out, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap", err)
|
||||
}
|
||||
|
||||
fund(t, player, MaxChipsOnTable-1000)
|
||||
|
||||
// A second buy-in that fits is fine.
|
||||
if _, err := RequestBuyIn(player, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A third, while the second is still in flight, must not clear the cap by
|
||||
// racing — pending buy-ins count against it.
|
||||
if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) {
|
||||
t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequest_RejectsNonPositiveAmounts(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 100)
|
||||
|
||||
for _, amount := range []int64{0, -50} {
|
||||
if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) {
|
||||
t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err)
|
||||
}
|
||||
}
|
||||
if c := chipsOf(t, player); c != 100 {
|
||||
t.Fatalf("chips = %d, want 100", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStakeAndAward(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 500)
|
||||
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 400 {
|
||||
t.Fatalf("chips = %d after staking 100, want 400", c)
|
||||
}
|
||||
|
||||
// A win pays the stake back plus the profit, net of rake.
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a 195 payout, want 595", c)
|
||||
}
|
||||
|
||||
// You cannot bet chips you don't have.
|
||||
if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 595 {
|
||||
t.Fatalf("chips = %d after a refused stake, want 595", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) {
|
||||
t.Fatalf("err = %v, want ErrInsufficientChips", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
fresh, err := RequestBuyIn(player, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
abandoned, err := RequestBuyIn("@other:parodia.dev", 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 2 {
|
||||
t.Fatalf("%d pending rows, want 2", len(pending))
|
||||
}
|
||||
|
||||
// Claim both: neither should be offered again while gogobee is working on them.
|
||||
for _, e := range []Escrow{fresh, abandoned} {
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("%d rows offered while claimed, want 0", len(pending))
|
||||
}
|
||||
|
||||
// Now pretend gogobee died holding one of them. A claim that never came back
|
||||
// must be re-offered, or the player's money sits in limbo forever.
|
||||
stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1
|
||||
if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != abandoned.GUID {
|
||||
t.Fatalf("stale claim was not re-offered: got %d rows", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaim_CannotReopenAFinishedRow(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 300)
|
||||
|
||||
e, err := RequestCashOut(player, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Re-claiming a settled cash-out must not walk it back to claimed, or gogobee
|
||||
// would pay the same euros out twice.
|
||||
got, err := ClaimEscrow(e.GUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != EscrowSettled {
|
||||
t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscrow_UnknownGUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) {
|
||||
t.Fatalf("err = %v, want ErrNoSuchEscrow", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaper_CashesOutTheWalkedAway(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 700)
|
||||
|
||||
// Still playing: the reaper must leave them alone.
|
||||
Touch(player)
|
||||
n, err := ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 || chipsOf(t, player) != 700 {
|
||||
t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player))
|
||||
}
|
||||
|
||||
// Now they've been gone an hour.
|
||||
old := nowUnix() - int64((2 * time.Hour).Seconds())
|
||||
if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err = ReapIdleSessions(SessionIdleAfter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("reaped %d, want 1", n)
|
||||
}
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c)
|
||||
}
|
||||
|
||||
// And it must be a real cash-out, waiting for gogobee to pay it out.
|
||||
pending, err := PendingEscrow(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 {
|
||||
t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending)
|
||||
}
|
||||
|
||||
// Running again finds nothing left to reap.
|
||||
if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 {
|
||||
t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHand_AndHouseTake(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
hands := []Hand{
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8},
|
||||
{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9},
|
||||
}
|
||||
for _, h := range hands {
|
||||
if err := RecordHand(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
take, err := HouseTake(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if take != 19 {
|
||||
t.Fatalf("house take = %d, want 19", take)
|
||||
}
|
||||
|
||||
// The seeds have to survive the round trip, or a disputed hand can't be re-dealt.
|
||||
var s1, s2 int64
|
||||
if err := Get().QueryRow(
|
||||
`SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`,
|
||||
).Scan(&s1, &s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s1 != 44 || s2 != 9 {
|
||||
t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant, end to end: every euro that entered the casino is either a chip
|
||||
// on the table or a euro on its way home. None are minted, none evaporate.
|
||||
func TestBorder_ChipsAreConserved(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
fund(t, player, 1000)
|
||||
|
||||
// Play a losing hand and a winning one.
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 900
|
||||
if err := Stake(player, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 800
|
||||
if err := Award(player, 195); err != nil {
|
||||
t.Fatal(err)
|
||||
} // 995 — one loss, one win less rake
|
||||
|
||||
if c := chipsOf(t, player); c != 995 {
|
||||
t.Fatalf("chips = %d, want 995", c)
|
||||
}
|
||||
|
||||
e, err := RequestCashOut(player, 995)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c := chipsOf(t, player); c != 0 {
|
||||
t.Fatalf("chips = %d after cashing out everything, want 0", c)
|
||||
}
|
||||
// 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded.
|
||||
st, err := Chips(player)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Pending != 0 {
|
||||
t.Fatalf("pending = %d, want 0", st.Pending)
|
||||
}
|
||||
}
|
||||
@@ -132,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
|
||||
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
|
||||
func GetStoryByGUID(guid string) (*Story, error) {
|
||||
row := Get().QueryRow(
|
||||
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
|
||||
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
|
||||
FROM stories WHERE guid = ?`, guid)
|
||||
var s Story
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
@@ -252,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
|
||||
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
|
||||
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
|
||||
// daily digest collects. Oldest first so the digest reads chronologically.
|
||||
//
|
||||
// At most `limit` rows come back, but total is how many match in all — the digest
|
||||
// quotes it to readers, so it must count the window rather than the returned page.
|
||||
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
|
||||
const where = `WHERE classified = 1
|
||||
AND channel = 'adventure'
|
||||
AND seen_at >= ?
|
||||
AND guid NOT IN (SELECT guid FROM post_log)`
|
||||
|
||||
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, headline, lede, article_url, seen_at
|
||||
FROM stories `+where+`
|
||||
ORDER BY seen_at ASC
|
||||
LIMIT ?`, since, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Story
|
||||
for rows.Next() {
|
||||
var s Story
|
||||
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
|
||||
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
|
||||
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
|
||||
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
|
||||
func MarkAdventureDigested(guids []string, eventID string) {
|
||||
for _, g := range guids {
|
||||
InsertPostLog(g, "adventure", eventID, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
|
||||
// real channel, newest first, with optional offset for pagination. Sentinel
|
||||
// channels (_discarded, _duplicate) are excluded.
|
||||
|
||||
108
internal/storage/roster.go
Normal file
108
internal/storage/roster.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
|
||||
type RosterEntry struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Status string `json:"status"` // "expedition" | "idle"
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
//
|
||||
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
|
||||
// character, or a fresh `!news optout`) must vanish from the board, and an
|
||||
// upsert would leave them standing there forever. The transaction means a reader
|
||||
// mid-swap sees the old board or the new one, never a half-empty realm.
|
||||
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Stamp the snapshot even when it carried zero adventurers — that is a
|
||||
// legitimate state (quiet realm) and must not read as "gogobee went away".
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LoadRoster returns the current board: everyone on an expedition first (most
|
||||
// recently departed at the top of that group), then the idle, longest-idle last.
|
||||
// Also returns the snapshot time so the caller can decide whether the wire has
|
||||
// gone quiet — see rosterStale.
|
||||
func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
rows, err := Get().Query(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||
FROM adventure_roster
|
||||
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RosterEntry
|
||||
for rows.Next() {
|
||||
var e RosterEntry
|
||||
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, RosterSnapshotAt(), nil
|
||||
}
|
||||
|
||||
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
|
||||
// never pushed one. Read from the meta row, not the entries, so a snapshot that
|
||||
// legitimately carried nobody still counts as a snapshot.
|
||||
func RosterSnapshotAt() int64 {
|
||||
var at sql.NullInt64
|
||||
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("RosterSnapshotAt query failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
return at.Int64
|
||||
}
|
||||
@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
|
||||
published_at INTEGER
|
||||
);
|
||||
|
||||
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
|
||||
-- board and it replaces this table wholesale. Rows are state that is currently
|
||||
-- true ("Josie is in holymachina"), which is the one thing the story feed can
|
||||
-- never be — every dispatch there is an accomplishment, and an accomplishment is
|
||||
-- a clipping the moment it lands.
|
||||
--
|
||||
-- token is gogobee's per-player roster token, not a Matrix handle and not a
|
||||
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
|
||||
-- upstream and so never appear here at all.
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster (
|
||||
token TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
class_race TEXT,
|
||||
status TEXT NOT NULL, -- "expedition" | "idle"
|
||||
zone TEXT,
|
||||
region TEXT,
|
||||
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
|
||||
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
|
||||
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
|
||||
);
|
||||
|
||||
-- The snapshot time lives outside the rows because an *empty* board is
|
||||
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
|
||||
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
|
||||
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
|
||||
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
@@ -128,6 +159,114 @@ CREATE TABLE IF NOT EXISTS story_views (
|
||||
PRIMARY KEY (story_id, day)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- games.parodia.dev
|
||||
--
|
||||
-- The invariant the whole casino rests on: a euro is either in gogobee's
|
||||
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
|
||||
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
|
||||
-- gogobee does, when it claims the escrow row and tells us how it went.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- A player's chips: euros that have crossed into the casino and haven't crossed
|
||||
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
|
||||
-- gogobee's ledger uses and the one an Authentik username maps onto.
|
||||
CREATE TABLE IF NOT EXISTS game_chips (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
chips INTEGER NOT NULL DEFAULT 0,
|
||||
-- Advisory only, and stale by design: the last euro balance gogobee told us
|
||||
-- about. Displayed, never trusted. The authoritative check is the debit at
|
||||
-- claim time, which happens on gogobee's box against gogobee's ledger.
|
||||
euro_balance REAL,
|
||||
last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- One crossing of the euro/chip border, in either direction.
|
||||
--
|
||||
-- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable)
|
||||
-- -> rejected (buy-in: insufficient funds, no chips)
|
||||
-- requested -> claimed -> settled (cash-out: chips gone, euros credited)
|
||||
--
|
||||
-- The guid is the idempotency key end to end: it's what gogobee passes to
|
||||
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
|
||||
-- without the player paying twice.
|
||||
CREATE TABLE IF NOT EXISTS game_escrow (
|
||||
guid TEXT PRIMARY KEY,
|
||||
matrix_user TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'buyin' | 'cashout'
|
||||
amount INTEGER NOT NULL, -- euros == chips
|
||||
state TEXT NOT NULL, -- see the ladder above
|
||||
reason TEXT, -- 'insufficient_funds', when rejected
|
||||
balance_after REAL, -- gogobee's euro balance after the move
|
||||
created_at INTEGER NOT NULL,
|
||||
claimed_at INTEGER, -- when gogobee took it; drives the re-poll
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);
|
||||
|
||||
-- Every hand played, for money. This is the audit trail: seeds so a disputed
|
||||
-- hand can be re-dealt exactly as it fell, rake so the house's take is
|
||||
-- accountable, and enough shape to answer "how fast is this economy actually
|
||||
-- moving" before the answer becomes a problem.
|
||||
CREATE TABLE IF NOT EXISTS game_hands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
matrix_user TEXT NOT NULL,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
bet INTEGER NOT NULL,
|
||||
payout INTEGER NOT NULL, -- chips returned, net of rake
|
||||
rake INTEGER NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
seed1 INTEGER NOT NULL, -- the shoe, reproducible
|
||||
seed2 INTEGER NOT NULL,
|
||||
played_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);
|
||||
|
||||
-- The hand a player is in the middle of. One per player: you cannot be dealt a
|
||||
-- second hand while chips are riding on the first.
|
||||
--
|
||||
-- The state column is the engine's State, serialized whole — shoe included. It
|
||||
-- lives here rather than in memory because Pete redeploys often, and a player
|
||||
-- whose stake has already been taken must find their cards where they left them
|
||||
-- rather than a table that has forgotten them. It is also why the deck never
|
||||
-- goes to the browser: the authoritative shoe is this row, on the server.
|
||||
CREATE TABLE IF NOT EXISTS game_live_hands (
|
||||
matrix_user TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL, -- 'blackjack'
|
||||
state TEXT NOT NULL, -- JSON: the engine's State
|
||||
seed1 INTEGER NOT NULL, -- carried to the audit log when it settles
|
||||
seed2 INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
|
||||
-- so that asking one is a local read.
|
||||
--
|
||||
-- Prefetched rather than fetched per question because a trivia ladder asks a
|
||||
-- question every fifteen seconds with money on a clock the player is scored
|
||||
-- against. A live fetch would put somebody else's latency and rate limit inside
|
||||
-- that clock. The refill is a slow background drip (internal/opentdb); a round
|
||||
-- never waits on it.
|
||||
--
|
||||
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
|
||||
-- overlapping batches and the bank would otherwise fill up with the same forty
|
||||
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
|
||||
-- happens in the engine, per game, against that game's seed — so where the right
|
||||
-- answer sits in this table tells a player nothing.
|
||||
CREATE TABLE IF NOT EXISTS trivia_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard'
|
||||
category TEXT NOT NULL,
|
||||
question TEXT NOT NULL UNIQUE,
|
||||
correct TEXT NOT NULL,
|
||||
incorrect TEXT NOT NULL, -- JSON array of the three wrong answers
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
|
||||
147
internal/storage/trivia.go
Normal file
147
internal/storage/trivia.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
)
|
||||
|
||||
// The trivia bank.
|
||||
//
|
||||
// Questions are pulled from OpenTDB in the background (internal/opentdb) and
|
||||
// drawn from here when a ladder is built. Nothing in a player's round ever
|
||||
// touches the network.
|
||||
|
||||
// ErrBankEmpty means the bank hasn't got enough questions of that difficulty to
|
||||
// build a ladder. It is a real state, not a bug: a fresh database has an empty
|
||||
// bank until the refill loop has been round a few times.
|
||||
var ErrBankEmpty = fmt.Errorf("trivia: the bank is short of questions")
|
||||
|
||||
// AddTriviaQuestions files a fetched batch. Questions already in the bank are
|
||||
// ignored rather than replaced — OpenTDB hands back overlapping batches, and the
|
||||
// UNIQUE on the text is what stops the bank becoming forty questions deep.
|
||||
// Returns how many were actually new, which is what the refill loop logs.
|
||||
func AddTriviaQuestions(difficulty string, qs []trivia.Question) (int, error) {
|
||||
if len(qs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op once committed
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT OR IGNORE INTO trivia_questions
|
||||
(difficulty, category, question, correct, incorrect, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("trivia: prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := time.Now().Unix()
|
||||
added := 0
|
||||
for _, q := range qs {
|
||||
if len(q.Answers) < 2 || q.Correct < 0 || q.Correct >= len(q.Answers) {
|
||||
continue
|
||||
}
|
||||
correct := q.Answers[q.Correct]
|
||||
wrong := make([]string, 0, len(q.Answers)-1)
|
||||
for i, a := range q.Answers {
|
||||
if i != q.Correct {
|
||||
wrong = append(wrong, a)
|
||||
}
|
||||
}
|
||||
blob, err := json.Marshal(wrong)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
res, err := stmt.Exec(difficulty, q.Category, q.Text, correct, string(blob), now)
|
||||
if err != nil {
|
||||
return added, fmt.Errorf("trivia: insert: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil {
|
||||
added += int(n)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("trivia: commit: %w", err)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// CountTrivia is how many questions of a difficulty the bank holds. The refill
|
||||
// loop reads it to decide whether to bother.
|
||||
func CountTrivia(difficulty string) (int, error) {
|
||||
var n int
|
||||
if err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM trivia_questions WHERE difficulty = ?`, difficulty,
|
||||
).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("trivia: count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DrawTrivia deals a ladder: n distinct questions of one difficulty, chosen with
|
||||
// the game's own rng.
|
||||
//
|
||||
// The choice is made in Go rather than with ORDER BY RANDOM() so that the seed
|
||||
// in the audit log means something: the same seed against the same bank deals
|
||||
// the same ladder, which is what lets a disputed game be replayed. It reads the
|
||||
// ids first and picks from them, so a bank of a few thousand questions costs one
|
||||
// small scan rather than a sort of the whole table.
|
||||
func DrawTrivia(difficulty string, n int, rng *rand.Rand) ([]trivia.Question, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT id FROM trivia_questions WHERE difficulty = ? ORDER BY id`, difficulty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("trivia: scan id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("trivia: draw ids: %w", err)
|
||||
}
|
||||
if len(ids) < n {
|
||||
return nil, ErrBankEmpty
|
||||
}
|
||||
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
pick := ids[:n]
|
||||
|
||||
out := make([]trivia.Question, 0, n)
|
||||
for _, id := range pick {
|
||||
var q trivia.Question
|
||||
var correct, blob string
|
||||
if err := Get().QueryRow(
|
||||
`SELECT category, question, correct, incorrect FROM trivia_questions WHERE id = ?`, id,
|
||||
).Scan(&q.Category, &q.Text, &correct, &blob); err != nil {
|
||||
return nil, fmt.Errorf("trivia: load question: %w", err)
|
||||
}
|
||||
var wrong []string
|
||||
if err := json.Unmarshal([]byte(blob), &wrong); err != nil {
|
||||
return nil, fmt.Errorf("trivia: unreadable answers: %w", err)
|
||||
}
|
||||
// Correct: 0 is a convention the engine immediately destroys — New()
|
||||
// reshuffles every question against the game's seed. Nothing that reaches a
|
||||
// player depends on the order they come out of the table in.
|
||||
q.Answers = append([]string{correct}, wrong...)
|
||||
q.Correct = 0
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
467
internal/web/adventure.go
Normal file
467
internal/web/adventure.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
|
||||
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
|
||||
// handles) and Actors is the allow-list of the only names permitted to appear in
|
||||
// rendered output.
|
||||
type AdvFact struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Count int `json:"count"`
|
||||
Outcome string `json:"outcome"`
|
||||
Stakes string `json:"stakes"`
|
||||
ClassRace string `json:"class_race"`
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
// web-local so the web package needs no dependency on internal/poster.
|
||||
type AdvPost struct {
|
||||
GUID string
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
Channel string
|
||||
}
|
||||
|
||||
// PriorityPoster posts a priority adventure item to Matrix immediately. main
|
||||
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
|
||||
type PriorityPoster func(AdvPost)
|
||||
|
||||
const advSource = "Pete"
|
||||
|
||||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||||
// Matrix; the row exists only so the digest skips it.
|
||||
const advBackfillEvent = "adv-backfill"
|
||||
|
||||
// handleAdventureIngest receives a game-event fact from gogobee, templates it
|
||||
// into a deterministic story, publishes it to the /adventure section, and posts
|
||||
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
|
||||
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var f AdvFact
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.GUID == "" || f.EventType == "" {
|
||||
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Fact-guard: any player name we render must be in the actors allow-list.
|
||||
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
|
||||
// last line before a name reaches a public page.
|
||||
if !factGuard(f) {
|
||||
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
|
||||
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
return
|
||||
}
|
||||
|
||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
occurredAt := f.OccurredAt
|
||||
if occurredAt <= 0 {
|
||||
occurredAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
articleURL := s.advPermalink(f.GUID)
|
||||
imageURL := advArtURL(f.EventType)
|
||||
if err := storage.InsertStory(&storage.Story{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ImageURL: imageURL,
|
||||
ArticleURL: articleURL,
|
||||
Source: advSource,
|
||||
Channel: "adventure",
|
||||
Classified: true,
|
||||
SeenAt: occurredAt,
|
||||
PublishedAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
// the live post isn't enough: the digest collects adventure rows that carry no
|
||||
// post_log entry, so a backfilled bulletin would still be swept into the next
|
||||
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
|
||||
// against the digest up front instead.
|
||||
if f.NoPush {
|
||||
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||||
// digest. Website section always gets the row above regardless of tier.
|
||||
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
|
||||
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
|
||||
// media), and the link's og:image carries the preview instead.
|
||||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||||
// (`ars technica`). On his own reporting it renders as him signing his
|
||||
// own name under his own message.
|
||||
s.advPost(AdvPost{
|
||||
GUID: f.GUID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: articleURL,
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// advEventMeta maps an event_type to a short display label and emoji used by the
|
||||
// permalink page (and, later, OG art selection). Unknown types fall back to a
|
||||
// neutral dispatch label so a new gogobee event never renders blank.
|
||||
func advEventMeta(eventType string) (label, emoji string) {
|
||||
switch eventType {
|
||||
case "siege_start", "siege_win", "siege_loss":
|
||||
return "The Siege", "🏰"
|
||||
case "boss_first", "boss_kill":
|
||||
return "Boss down", "🐉"
|
||||
case "zone_first":
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
return "New arrival", "👋"
|
||||
case "standings", "rival_result":
|
||||
return "The rival board", "⚔️"
|
||||
case "pete_duel_loss", "pete_duel_win":
|
||||
return "Pete's duels", "🤝"
|
||||
case "milestone":
|
||||
return "Milestone", "🏅"
|
||||
case "retreat":
|
||||
return "Pulled out", "🎒"
|
||||
case "departure":
|
||||
return "Wandered off", "🚪"
|
||||
case "mischief_contract":
|
||||
return "Coin on their head", "😈"
|
||||
case "mischief_survived":
|
||||
return "They walked away", "🛡️"
|
||||
case "mischief_downed":
|
||||
return "The contract landed", "💀"
|
||||
case "mischief_fizzled":
|
||||
return "Nobody home", "🚪"
|
||||
}
|
||||
return "Dispatch", "📣"
|
||||
}
|
||||
|
||||
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
|
||||
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
|
||||
// the external-image thumbnailer.
|
||||
func advArtURL(eventType string) string {
|
||||
return "/adventure/art/" + eventType + ".svg"
|
||||
}
|
||||
|
||||
// handleAdventureArt renders the themed emblem for an event type — an adventure
|
||||
// gradient with the event's emoji and label. Deterministic and dependency-free
|
||||
// (no external asset), so every dispatch card has visual identity instead of the
|
||||
// blank placeholder that made the section look broken next to RSS cards.
|
||||
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
|
||||
}
|
||||
|
||||
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
|
||||
// card ratio) so the same image works as a link-preview image.
|
||||
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c5ce8"/>
|
||||
<stop offset="1" stop-color="#5836b8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#g)"/>
|
||||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||||
</svg>`
|
||||
|
||||
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
|
||||
// dispatch reads like the rest of the site, with an adventure-themed hero.
|
||||
type advStoryPage struct {
|
||||
pageData
|
||||
EventLabel string
|
||||
Emoji string
|
||||
Headline string
|
||||
Body string
|
||||
Region string
|
||||
When string
|
||||
Permalink string
|
||||
}
|
||||
|
||||
// handleAdventureStory serves the server-rendered permalink for one dispatch
|
||||
// (the article_url every ingested story points at). Public and cacheable; 404s
|
||||
// when the section is disabled or the guid is unknown. Character names in the
|
||||
// stored headline/body already passed the ingest fact-guard, so nothing
|
||||
// player-controlled reaches here unchecked.
|
||||
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
guid := r.PathValue("guid")
|
||||
st, err := storage.GetStoryByGUID(guid)
|
||||
if err != nil || st == nil || st.Channel != "adventure" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.track(r, "adventure")
|
||||
|
||||
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
|
||||
// back to the neutral dispatch meta when it isn't a known type.
|
||||
eventType, _, _ := strings.Cut(guid, ":")
|
||||
label, emoji := advEventMeta(eventType)
|
||||
|
||||
body := st.Content
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = st.Lede // template-only dispatches carry the write-up in the lede
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
|
||||
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
|
||||
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
|
||||
}
|
||||
s.render(w, "story", advStoryPage{
|
||||
pageData: base,
|
||||
EventLabel: label,
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
})
|
||||
}
|
||||
|
||||
// bearerOK checks the Authorization: Bearer header against the configured ingest
|
||||
// token in constant time.
|
||||
func (s *Server) bearerOK(r *http.Request) bool {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimPrefix(h, prefix)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
|
||||
}
|
||||
|
||||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||||
// configured the relative form is all we have, and it's still fine on-site.
|
||||
func (s *Server) siteURL(path string) string {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||||
}
|
||||
|
||||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||||
func (s *Server) advPermalink(guid string) string {
|
||||
return s.siteURL("/adventure/" + url.PathEscape(guid))
|
||||
}
|
||||
|
||||
// factGuard verifies every player-name field we might render is present in the
|
||||
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
|
||||
// player-controlled, so they are not guarded.
|
||||
func factGuard(f AdvFact) bool {
|
||||
allow := make(map[string]bool, len(f.Actors))
|
||||
for _, a := range f.Actors {
|
||||
if a != "" {
|
||||
allow[a] = true
|
||||
}
|
||||
}
|
||||
if f.Subject != "" && !allow[f.Subject] {
|
||||
return false
|
||||
}
|
||||
if f.Opponent != "" && !allow[f.Opponent] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
atLevel := ""
|
||||
if f.Level > 0 {
|
||||
atLevel = fmt.Sprintf(", at level %d", f.Level)
|
||||
}
|
||||
switch f.EventType {
|
||||
case "siege_start":
|
||||
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
|
||||
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
|
||||
case "siege_win":
|
||||
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
|
||||
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
|
||||
case "siege_loss":
|
||||
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
|
||||
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
|
||||
case "boss_first":
|
||||
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
|
||||
case "boss_kill":
|
||||
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
|
||||
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
|
||||
case "zone_first", "zone_clear":
|
||||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||||
// split.
|
||||
if f.EventType == "zone_first" || f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
|
||||
}
|
||||
inRegion := ""
|
||||
if f.Region != "" {
|
||||
inRegion = " in " + f.Region
|
||||
}
|
||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||
case "death":
|
||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||
case "retreat":
|
||||
// An expedition that came apart without killing anyone. Until gogobee
|
||||
// started sending these, the feed had no way to say "it went badly and
|
||||
// everyone lived" — so it never said it, and the classes that retreat
|
||||
// often simply never appeared. Warm, not a failure notice: everyone came
|
||||
// home, and that is the part Pete leads with.
|
||||
howFar := "barely a day in"
|
||||
if f.Count > 1 {
|
||||
howFar = fmt.Sprintf("%d days in", f.Count)
|
||||
}
|
||||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||||
f.Subject, howFar, f.Zone, atLevel), true
|
||||
case "departure":
|
||||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||||
// they could afford, and went. Pete plays it straight and a little fond;
|
||||
// the joke tells itself, and the player it's about may well be reading.
|
||||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||||
case "mischief_contract":
|
||||
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||||
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||||
// the anonymity is the story: Pete reports the money, not the name he
|
||||
// doesn't have. Opponent carries the buyer only when it's public.
|
||||
if f.Opponent != "" {
|
||||
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||||
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||||
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||||
}
|
||||
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||||
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||||
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||||
case "mischief_survived":
|
||||
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||||
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||||
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||||
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||||
case "mischief_downed":
|
||||
who := "Nobody's saying who paid for it"
|
||||
if f.Opponent != "" {
|
||||
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||||
}
|
||||
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||||
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||||
f.Boss, f.Subject, atLevel, who), true
|
||||
case "mischief_fizzled":
|
||||
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||||
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||||
f.Subject, f.Subject), true
|
||||
case "arrival":
|
||||
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
|
||||
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
|
||||
case "standings":
|
||||
return "The rival board's been shaken up.",
|
||||
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
|
||||
case "rival_result":
|
||||
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
|
||||
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
|
||||
case "pete_duel_loss":
|
||||
if f.Tier == "priority" {
|
||||
headline = fmt.Sprintf("You got me, %s.", f.Subject)
|
||||
} else {
|
||||
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
|
||||
}
|
||||
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
|
||||
case "pete_duel_win":
|
||||
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
|
||||
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
|
||||
case "milestone":
|
||||
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
|
||||
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
154
internal/web/adventure_digest.go
Normal file
154
internal/web/adventure_digest.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
||||
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
||||
// that were seen since the last digest but never posted live — exactly the
|
||||
// bulletins — and posts a single warm roundup to the adventure channel. The
|
||||
// website already carries each one as its own card; the digest is the Matrix-only
|
||||
// nudge so quiet-but-real activity surfaces without one ping per event.
|
||||
|
||||
const (
|
||||
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
||||
// missed run (process down over a digest hour) still sweeps up the gap;
|
||||
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
||||
digestWindow = 48 * time.Hour
|
||||
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
||||
// community, per the voice spec); a runaway just truncates with a log line.
|
||||
digestCap = 40
|
||||
// digestPreview is how many headlines the roundup lede lists by name before
|
||||
// collapsing the rest to "…and N more".
|
||||
digestPreview = 4
|
||||
)
|
||||
|
||||
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
||||
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
||||
// i.e. website-only and local modes never post a digest.
|
||||
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
||||
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
||||
return
|
||||
}
|
||||
go s.runAdventureDigest(ctx)
|
||||
}
|
||||
|
||||
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
||||
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
||||
// digest at a predictable time of day across restarts.
|
||||
func (s *Server) runAdventureDigest(ctx context.Context) {
|
||||
hour := s.adv.DigestHourOrDefault()
|
||||
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
|
||||
for {
|
||||
wait := durUntilNextHour(time.Now().UTC(), hour)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
s.postDailyDigest(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// durUntilNextHour returns the duration from now to the next occurrence of the
|
||||
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
||||
// at the hour doesn't double-fire.
|
||||
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
||||
// and marks them digested so they don't recur. Silent when there's nothing new —
|
||||
// a dormant realm should stay quiet, not ship an empty digest.
|
||||
func (s *Server) postDailyDigest(now time.Time) {
|
||||
since := now.Add(-digestWindow).Unix()
|
||||
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
|
||||
if err != nil {
|
||||
slog.Error("adventure digest: query failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(bulletins) == 0 {
|
||||
return
|
||||
}
|
||||
if total > len(bulletins) {
|
||||
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
|
||||
}
|
||||
|
||||
date := now.Format("2006-01-02")
|
||||
eventID := "adv-digest:" + date
|
||||
headline, lede := buildDigest(bulletins, total)
|
||||
|
||||
s.advPost(AdvPost{
|
||||
GUID: eventID,
|
||||
Headline: headline,
|
||||
Lede: lede,
|
||||
ArticleURL: s.digestURL(date),
|
||||
Channel: s.adv.Channel,
|
||||
})
|
||||
|
||||
guids := make([]string, len(bulletins))
|
||||
for i, b := range bulletins {
|
||||
guids[i] = b.GUID
|
||||
}
|
||||
storage.MarkAdventureDigested(guids, eventID)
|
||||
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
||||
}
|
||||
|
||||
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
||||
// total is how many bulletins the window actually holds, which is larger than
|
||||
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
|
||||
// have to describe the realm, not the slice. Headlines come from stored,
|
||||
// fact-guarded rows, so no player-controlled text is introduced here.
|
||||
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
|
||||
if total == 1 {
|
||||
return "Today in the realm: one dispatch.",
|
||||
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
||||
}
|
||||
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
|
||||
|
||||
shown := bulletins
|
||||
if len(shown) > digestPreview {
|
||||
shown = shown[:digestPreview]
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, b := range shown {
|
||||
parts[i] = trimHeadline(b.Headline)
|
||||
}
|
||||
list := strings.Join(parts, " ")
|
||||
more := ""
|
||||
if total > len(shown) {
|
||||
more = fmt.Sprintf(" …and %d more.", total-len(shown))
|
||||
}
|
||||
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
||||
}
|
||||
|
||||
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
||||
// lede reads as prose rather than a run-on.
|
||||
func trimHeadline(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
||||
h += "."
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// digestURL points the roundup at the section, with a per-day query param so the
|
||||
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
||||
// tracking params, keeping ?digest=).
|
||||
func (s *Server) digestURL(date string) string {
|
||||
return s.siteURL("/adventure?digest=" + date)
|
||||
}
|
||||
55
internal/web/adventure_retreat_test.go
Normal file
55
internal/web/adventure_retreat_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The retreat bulletin — gogobee's newest event type.
|
||||
//
|
||||
// The failure this guards against is silent and total: handleAdventureIngest
|
||||
// answers an unrecognized event_type with 400, and gogobee's sender treats any
|
||||
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
|
||||
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
|
||||
// know the word doesn't log an error anyone reads — it just quietly drops every
|
||||
// retreat the realm ever files. Pete has to learn the word first, and this test
|
||||
// is what says he has.
|
||||
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
|
||||
OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
|
||||
"retry it eight times and park it forever", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("retreat:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("retreat was accepted but not stored: %v", err)
|
||||
}
|
||||
// It has to actually say what happened, in Pete's voice, using only the
|
||||
// supplied facts.
|
||||
body := got.Headline + " " + got.Lede
|
||||
for _, want := range []string{"Brannigan", "the Underforge"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("rendered retreat is missing %q: %q", want, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.Lede, "3 days in") {
|
||||
t.Errorf("the day count never made it into the copy: %q", got.Lede)
|
||||
}
|
||||
// Bulletin, not priority: a retreat goes in the daily digest, it does not
|
||||
// interrupt the room. Pete announcing every failed run live would be a
|
||||
// firehose — and an unkind one.
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("a bulletin was posted live: %+v", *posted)
|
||||
}
|
||||
}
|
||||
371
internal/web/adventure_test.go
Normal file
371
internal/web/adventure_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newAdvServer builds a web server with the adventure seam enabled and a
|
||||
// capturing priority poster, backed by a fresh temp DB.
|
||||
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
|
||||
t.Helper()
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
|
||||
var posted []AdvPost
|
||||
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
|
||||
// Mirror the production poster's side effect: queue.PostNow records a
|
||||
// post_log row, which is exactly what marks a beat "posted" and thus
|
||||
// excludes it from the bulletin digest.
|
||||
poster := func(p AdvPost) {
|
||||
posted = append(posted, p)
|
||||
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
|
||||
}
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
|
||||
nil, true, adv, poster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, &posted
|
||||
}
|
||||
|
||||
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(rw, req)
|
||||
return rw
|
||||
}
|
||||
|
||||
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
|
||||
// bearer-accepted, templated, stored as an adventure story, and posted live.
|
||||
func TestAdventureIngestEndToEnd(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("death:abc:1000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Channel != "adventure" || got.Source != advSource {
|
||||
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
|
||||
}
|
||||
if got.Headline != "We lost Brannigan in the Underforge." {
|
||||
t.Errorf("headline = %q", got.Headline)
|
||||
}
|
||||
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
|
||||
t.Errorf("article_url = %q", got.ArticleURL)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
|
||||
t.Fatalf("priority post not delivered: %+v", *posted)
|
||||
}
|
||||
|
||||
// Idempotent re-delivery: no error, no second post.
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("dup ingest status = %d", rw.Code)
|
||||
}
|
||||
if len(*posted) != 1 {
|
||||
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePermalink renders the per-story page the article_url points at:
|
||||
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
|
||||
// and body, and an unknown guid must 404.
|
||||
func TestAdventurePermalink(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
req.SetPathValue("guid", "death:abc:1000")
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
body := rw.Body.String()
|
||||
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
|
||||
t.Errorf("permalink missing headline; body=%s", body)
|
||||
}
|
||||
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
|
||||
t.Errorf("permalink missing event label; body=%s", body)
|
||||
}
|
||||
|
||||
// Unknown guid 404s.
|
||||
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
|
||||
req2.SetPathValue("guid", "nope:1")
|
||||
rw2 := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw2, req2)
|
||||
if rw2.Code != 404 {
|
||||
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
|
||||
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
|
||||
func TestDurUntilNextHour(t *testing.T) {
|
||||
// 14:30 UTC, targeting 17:00 → 2h30m today.
|
||||
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
|
||||
t.Errorf("before hour: got %v", got)
|
||||
}
|
||||
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
|
||||
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
|
||||
t.Errorf("at hour: got %v", got)
|
||||
}
|
||||
// 20:00, targeting 17:00 → tomorrow, 21h.
|
||||
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
|
||||
t.Errorf("after hour: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDigest covers the batched path: bulletins (no live post) are
|
||||
// collected into one roundup, priority beats are excluded (they already posted),
|
||||
// digested bulletins don't recur, and an empty window stays silent.
|
||||
func TestAdventureDigest(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
now := time.Now()
|
||||
|
||||
// Two bulletins + one priority (which posts live and must be excluded).
|
||||
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
|
||||
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
|
||||
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
|
||||
if len(*posted) != 1 {
|
||||
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
|
||||
}
|
||||
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
|
||||
}
|
||||
dg := (*posted)[1]
|
||||
if !strings.Contains(dg.Headline, "2 dispatches") {
|
||||
t.Errorf("digest headline = %q", dg.Headline)
|
||||
}
|
||||
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
|
||||
t.Errorf("digest guid = %q", dg.GUID)
|
||||
}
|
||||
|
||||
// Re-running finds nothing new (bulletins marked digested).
|
||||
s.postDailyDigest(now.UTC())
|
||||
if len(*posted) != 2 {
|
||||
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
|
||||
// returns a themed SVG, ingested cards carry its local path, and the permalink
|
||||
// page is noindex with an og:image.
|
||||
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
|
||||
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
|
||||
func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl, _, ok := renderAdventure(first)
|
||||
if !ok || !strings.Contains(hl, "very first time") {
|
||||
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
|
||||
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
|
||||
hl2, _, ok := renderAdventure(repeat)
|
||||
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
|
||||
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
// The permalink label distinguishes the two, so a repeat's page isn't stamped
|
||||
// "First clear".
|
||||
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
|
||||
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdventureArtAndMeta(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
// Emblem endpoint returns SVG with the event's emoji.
|
||||
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
|
||||
areq.SetPathValue("type", "death.svg")
|
||||
arw := httptest.NewRecorder()
|
||||
s.handleAdventureArt(arw, areq)
|
||||
if arw.Code != 200 {
|
||||
t.Fatalf("art status = %d", arw.Code)
|
||||
}
|
||||
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
|
||||
t.Errorf("art content-type = %q", ct)
|
||||
}
|
||||
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
|
||||
t.Errorf("art body missing emblem: %s", b)
|
||||
}
|
||||
|
||||
// Ingest sets the card image to the local emblem path.
|
||||
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID("death:abc:1000")
|
||||
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
|
||||
t.Errorf("story image = %q", got.ImageURL)
|
||||
}
|
||||
|
||||
// Permalink page is noindex with an og:image.
|
||||
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
|
||||
preq.SetPathValue("guid", "death:abc:1000")
|
||||
prw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(prw, preq)
|
||||
body := prw.Body.String()
|
||||
if !strings.Contains(body, `name="robots" content="noindex"`) {
|
||||
t.Error("permalink not noindex")
|
||||
}
|
||||
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
|
||||
t.Errorf("permalink missing og:image; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureIngestBearer rejects missing/wrong tokens.
|
||||
func TestAdventureIngestBearer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "right")
|
||||
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "", f); rw.Code != 401 {
|
||||
t.Errorf("no token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
|
||||
t.Errorf("wrong token: status = %d, want 401", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
|
||||
// so a name that slipped the source can't reach a public page.
|
||||
func TestAdventureFactGuard(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "death:evil:1", EventType: "death", Tier: "priority",
|
||||
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
|
||||
Zone: "the Underforge", Level: 3, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("death:evil:1") {
|
||||
t.Error("guarded fact was stored")
|
||||
}
|
||||
if len(*posted) != 0 {
|
||||
t.Error("guarded fact was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventureDisabled 404s when the seam is off.
|
||||
func TestAdventureDisabled(t *testing.T) {
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { storage.Close() })
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
|
||||
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
|
||||
t.Errorf("disabled: status = %d, want 404", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
||||
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
||||
//
|
||||
// It also pins the anonymity contract, which is the feature's whole social
|
||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||
func TestRenderMischief(t *testing.T) {
|
||||
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
|
||||
hl, lede, ok := renderAdventure(anon)
|
||||
if !ok {
|
||||
t.Fatal("mischief_contract did not render — ingest would 400")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous contract leaked a buyer name")
|
||||
}
|
||||
if !strings.Contains(hl, "€350") {
|
||||
t.Errorf("contract headline lost the stakes: %q", hl)
|
||||
}
|
||||
|
||||
signed := anon
|
||||
signed.Opponent = "Brannigan"
|
||||
hl, _, ok = renderAdventure(signed)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// The unseal: a survival names the buyer whether or not they signed.
|
||||
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
|
||||
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
|
||||
hl, _, ok = renderAdventure(survived)
|
||||
if !ok || !strings.Contains(hl, "Brannigan") {
|
||||
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
|
||||
}
|
||||
|
||||
// A downed target with an anonymous buyer stays anonymous — being maimed
|
||||
// doesn't buy you the name.
|
||||
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
|
||||
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
|
||||
hl, lede, ok = renderAdventure(downed)
|
||||
if !ok {
|
||||
t.Fatal("mischief_downed did not render")
|
||||
}
|
||||
if strings.Contains(hl+lede, "Brannigan") {
|
||||
t.Error("anonymous buyer named on a downed contract")
|
||||
}
|
||||
|
||||
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
|
||||
t.Error("mischief_fizzled did not render")
|
||||
}
|
||||
|
||||
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
|
||||
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
|
||||
t.Errorf("%s has no permalink label", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,7 @@ type Authenticator struct {
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
secret []byte
|
||||
domain string // cookie Domain; empty means host-only
|
||||
}
|
||||
|
||||
// SessionUser is the identity carried in the signed session cookie.
|
||||
@@ -39,9 +41,25 @@ type SessionUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
// Username is the Authentik preferred_username, which MAS imported as the
|
||||
// Matrix localpart — so it is also who this person is in the game economy.
|
||||
// Sessions signed before games existed don't carry it; MatrixUser returns
|
||||
// "" for those and the caller sends them back through sign-in.
|
||||
Username string `json:"username,omitempty"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// MatrixUser maps the session to a Matrix ID on the given server name, e.g.
|
||||
// "reala" on "parodia.dev" -> "@reala:parodia.dev". Empty if either half is
|
||||
// missing, which callers must treat as "not identified in the economy".
|
||||
func (u *SessionUser) MatrixUser(serverName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(u.Username))
|
||||
if name == "" || serverName == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + name + ":" + serverName
|
||||
}
|
||||
|
||||
// Display is the friendly name shown in the header (name, else email, else sub).
|
||||
func (u *SessionUser) Display() string {
|
||||
if u.Name != "" {
|
||||
@@ -88,6 +106,7 @@ func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticato
|
||||
},
|
||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
secret: []byte(cfg.SessionSecret),
|
||||
domain: strings.TrimSpace(cfg.CookieDomain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -150,11 +169,23 @@ func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
|
||||
return &u
|
||||
}
|
||||
|
||||
// cookieDomain is the Domain attribute for a given cookie. Only the session
|
||||
// cookie is widened: the OAuth round-trip cookie stays host-only, because it
|
||||
// pairs with a redirect back to the host that started the login and has no
|
||||
// business being readable from anywhere else.
|
||||
func (a *Authenticator) cookieDomain(name string) string {
|
||||
if name == sessionCookie {
|
||||
return a.domain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Domain: a.cookieDomain(name),
|
||||
Expires: time.Now().Add(ttl),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -166,10 +197,42 @@ func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl
|
||||
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name, Value: "", Path: "/", MaxAge: -1,
|
||||
Domain: a.cookieDomain(name),
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// oauthFor returns the OAuth config to use for this request. The configured
|
||||
// redirect_url names one host (news), but a login that starts on games has to
|
||||
// come back to games — otherwise the browser is dumped on the news site with
|
||||
// its "next" path pointing at a page that lives elsewhere. So when the request
|
||||
// arrives on a host inside the shared cookie domain, keep the redirect on that
|
||||
// host, reusing the configured URL's scheme and path. Every host used this way
|
||||
// must be registered as a redirect URI in Authentik.
|
||||
func (a *Authenticator) oauthFor(r *http.Request) *oauth2.Config {
|
||||
if a.domain == "" || !hostInDomain(r.Host, a.domain) {
|
||||
return a.oauth
|
||||
}
|
||||
u, err := url.Parse(a.oauth.RedirectURL)
|
||||
if err != nil || u.Host == r.Host {
|
||||
return a.oauth
|
||||
}
|
||||
cfg := *a.oauth
|
||||
cfg.RedirectURL = u.Scheme + "://" + r.Host + u.Path
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// hostInDomain reports whether host sits inside a cookie domain like
|
||||
// ".parodia.dev" (which also covers the bare "parodia.dev").
|
||||
func hostInDomain(host, domain string) bool {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i] // strip port
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
bare := strings.TrimPrefix(strings.ToLower(domain), ".")
|
||||
return host == bare || strings.HasSuffix(host, "."+bare)
|
||||
}
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
|
||||
// handleLogin starts the OIDC authorization-code flow.
|
||||
@@ -182,7 +245,7 @@ func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload, _ := json.Marshal(st)
|
||||
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
||||
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||
http.Redirect(w, r, a.oauthFor(r).AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleCallback completes the flow: validates state, exchanges the code,
|
||||
@@ -212,7 +275,7 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
|
||||
tok, err := a.oauthFor(r).Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
slog.Error("auth: code exchange failed", "err", err)
|
||||
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||
@@ -247,7 +310,13 @@ func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if name == "" {
|
||||
name = claims.PreferredUsername
|
||||
}
|
||||
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
|
||||
u := SessionUser{
|
||||
Sub: claims.Sub,
|
||||
Name: name,
|
||||
Email: claims.Email,
|
||||
Username: claims.PreferredUsername,
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
}
|
||||
sess, _ := json.Marshal(u)
|
||||
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
||||
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
@@ -53,3 +59,95 @@ func TestSafeNext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatrixUser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
u SessionUser
|
||||
want string
|
||||
}{
|
||||
{"lowercased", SessionUser{Username: "Reala"}, "@reala:parodia.dev"},
|
||||
{"trimmed", SessionUser{Username: " reala "}, "@reala:parodia.dev"},
|
||||
{"old session with no username", SessionUser{Sub: "abc", Name: "Reala"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.u.MatrixUser("parodia.dev"); got != tc.want {
|
||||
t.Fatalf("MatrixUser = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := (&SessionUser{Username: "reala"}).MatrixUser(""); got != "" {
|
||||
t.Fatalf("no server name should yield no identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, domain string
|
||||
want bool
|
||||
}{
|
||||
{"games.parodia.dev", ".parodia.dev", true},
|
||||
{"news.parodia.dev:8080", ".parodia.dev", true},
|
||||
{"parodia.dev", ".parodia.dev", true},
|
||||
{"GAMES.PARODIA.DEV", ".parodia.dev", true},
|
||||
{"evil.com", ".parodia.dev", false},
|
||||
// The suffix check must not match a domain that merely ends in the
|
||||
// same letters: notparodia.dev is a different site entirely.
|
||||
{"notparodia.dev", ".parodia.dev", false},
|
||||
{"games.parodia.dev.evil.com", ".parodia.dev", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hostInDomain(tc.host, tc.domain); got != tc.want {
|
||||
t.Errorf("hostInDomain(%q, %q) = %v, want %v", tc.host, tc.domain, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthForKeepsLoginOnTheHostItStartedOn(t *testing.T) {
|
||||
base := &oauth2.Config{RedirectURL: "https://news.parodia.dev/auth/callback"}
|
||||
a := &Authenticator{oauth: base, domain: ".parodia.dev"}
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/login", nil)
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != "https://games.parodia.dev/auth/callback" {
|
||||
t.Fatalf("games login should come back to games, got %q", got)
|
||||
}
|
||||
|
||||
req.Host = "news.parodia.dev"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("news login should use the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// A Host we don't own must never be echoed back into a redirect URI.
|
||||
req.Host = "evil.com"
|
||||
if got := a.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("foreign host must fall back to the configured URL, got %q", got)
|
||||
}
|
||||
|
||||
// With no cookie domain configured there is nothing to share, so the
|
||||
// configured redirect stands whatever the Host header says.
|
||||
off := &Authenticator{oauth: base}
|
||||
req.Host = "games.parodia.dev"
|
||||
if got := off.oauthFor(req).RedirectURL; got != base.RedirectURL {
|
||||
t.Fatalf("host-only mode must not rewrite the redirect, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsSharedButOAuthCookieIsNot(t *testing.T) {
|
||||
a := &Authenticator{secret: []byte("test-secret-key-at-least-16"), domain: ".parodia.dev"}
|
||||
rec := httptest.NewRecorder()
|
||||
a.setCookie(rec, sessionCookie, "v", time.Minute)
|
||||
a.setCookie(rec, oauthCookie, "v", time.Minute)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
got[c.Name] = c.Domain
|
||||
}
|
||||
if got[sessionCookie] != "parodia.dev" {
|
||||
t.Fatalf("session cookie domain = %q, want it shared across parodia.dev", got[sessionCookie])
|
||||
}
|
||||
if got[oauthCookie] != "" {
|
||||
t.Fatalf("oauth cookie must stay host-only, got domain %q", got[oauthCookie])
|
||||
}
|
||||
}
|
||||
|
||||
111
internal/web/devcasino_test.go
Normal file
111
internal/web/devcasino_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/opentdb"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestDevCasino is not a test. It is the casino, running, on a port, with one
|
||||
// signed-in player who has chips — so the table can be driven in a real browser,
|
||||
// which is the only honest way to review an animation.
|
||||
//
|
||||
// Skipped unless you ask for it:
|
||||
//
|
||||
// PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0
|
||||
//
|
||||
// It prints the session cookie to plant. The routes are wired here rather than
|
||||
// taken from New(), because New() decides whether the casino exists at the
|
||||
// moment it builds the mux, and the test rig only signs the player in afterwards.
|
||||
func TestDevCasino(t *testing.T) {
|
||||
addr := os.Getenv("PETE_DEV_CASINO")
|
||||
if addr == "" {
|
||||
t.Skip("set PETE_DEV_CASINO=:port to run the casino for a browser")
|
||||
}
|
||||
|
||||
s := newCasino(t)
|
||||
fund(t, 5000)
|
||||
seedTriviaBank(t)
|
||||
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: "sub-1", Username: "reala", Name: "Reala",
|
||||
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
||||
})
|
||||
cookie := s.auth.sign(payload)
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
s.casinoRoutes(mux)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written to a file, not printed: `go test` buffers stdout, and the browser
|
||||
// driver needs the cookie while the server is still running.
|
||||
if out := os.Getenv("PETE_DEV_COOKIE_FILE"); out != "" {
|
||||
if err := os.WriteFile(out, []byte(cookie), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nCASINO http://localhost%s/games\nCOOKIE %s=%s\n\n", addr, sessionCookie, cookie)
|
||||
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
_ = srv.Serve(ln)
|
||||
}
|
||||
|
||||
// seedTriviaBank puts enough questions in the bank to deal a ladder of each
|
||||
// difficulty.
|
||||
//
|
||||
// The rig does not run StartTriviaBank — a dev casino that spends its first two
|
||||
// minutes dripping four hundred questions per difficulty out of a free API is a
|
||||
// dev casino you cannot use. But a fresh database has an empty bank, and an
|
||||
// empty bank means every start 503s, so the rig would be unable to show you the
|
||||
// one game it exists to show you.
|
||||
//
|
||||
// One real batch per difficulty, through the real client: fifty questions is
|
||||
// four ladders' worth, and it means what the browser renders came out of OpenTDB
|
||||
// and through the same decode-and-store path production uses, entities and all.
|
||||
func seedTriviaBank(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
client := opentdb.New()
|
||||
|
||||
for i, tier := range trivia.Tiers {
|
||||
have, err := storage.CountTrivia(tier.Difficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have >= trivia.Rungs {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(opentdb.Politeness) // the API asks; asking faster earns nothing
|
||||
}
|
||||
qs, err := client.Fetch(ctx, tier.Difficulty, opentdb.Batch)
|
||||
if err != nil {
|
||||
t.Fatalf("seeding the trivia bank (%s): %v", tier.Difficulty, err)
|
||||
}
|
||||
added, err := storage.AddTriviaQuestions(tier.Difficulty, qs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Printf("BANK %-6s %d questions\n", tier.Difficulty, added)
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
151
internal/web/games.go
Normal file
151
internal/web/games.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The euro/chip wire.
|
||||
//
|
||||
// gogobee owns the euros and has no inbound API, so it is the only initiator:
|
||||
// it polls this endpoint for border crossings, moves the money on its side, and
|
||||
// pushes the verdict back through the durable queue it already uses for
|
||||
// adventure facts. Pete never calls gogobee. That direction of travel is a
|
||||
// standing rule of the seam, not an implementation detail — see roster.go.
|
||||
//
|
||||
// All three endpoints are bearer-authed with the same ingest token as the
|
||||
// adventure seam, and all three are idempotent, because the thing on the other
|
||||
// end of them is a retrying queue and the thing they move is money.
|
||||
//
|
||||
// The storage layer under this (internal/storage/games.go) is where the actual
|
||||
// invariant lives: chips exist only once gogobee confirms it took the euros.
|
||||
// These handlers are transport, and deliberately nothing more.
|
||||
|
||||
// escrowGUID is the body of the two POSTs that name a row.
|
||||
type escrowGUID struct {
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
|
||||
// escrowVerdict is gogobee's answer: did the money move, and what is the
|
||||
// player's euro balance now.
|
||||
type escrowVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
}
|
||||
|
||||
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
|
||||
//
|
||||
// It includes rows gogobee claimed but never reported on — see
|
||||
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
|
||||
// idempotency key: if gogobee already moved the euros, the retry is a no-op
|
||||
// that reports the same answer, and if it died before moving them, the money
|
||||
// gets moved now instead of being stranded.
|
||||
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rows, err := storage.PendingEscrow(escrowPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("games: pending escrow", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []storage.Escrow{} // an empty poll is [], never null
|
||||
}
|
||||
writeJSON(w, rows)
|
||||
}
|
||||
|
||||
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
|
||||
// drains in a handful of ticks rather than arriving as one enormous body.
|
||||
const escrowPollLimit = 50
|
||||
|
||||
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
|
||||
// claimed can be claimed again, which is how a stale re-offer works — but a row
|
||||
// that has already reached a verdict cannot be, which is what stops a settled
|
||||
// cash-out being paid twice.
|
||||
//
|
||||
// The claimed row goes back in the response, so gogobee moves the money against
|
||||
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
|
||||
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req escrowGUID
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.ClaimEscrow(req.GUID)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
http.Error(w, "no such escrow", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
|
||||
// ever created, and it runs exactly once per guid no matter how many times the
|
||||
// push is redelivered.
|
||||
//
|
||||
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
|
||||
// already moved real euros for a row Pete has no record of. Under the contract
|
||||
// the adventure seam established, a 400 makes gogobee's sender park the row
|
||||
// instead of retrying it forever — which is right, because no amount of retrying
|
||||
// invents the missing row. It leaves the payload sitting in gogobee's queue,
|
||||
// where a human can find it.
|
||||
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v escrowVerdict
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
|
||||
if errors.Is(err, storage.ErrNoSuchEscrow) {
|
||||
slog.Error("games: verdict for an escrow row we have never heard of — "+
|
||||
"gogobee has moved euros against it and Pete cannot honour them",
|
||||
"guid", v.GUID, "ok", v.OK)
|
||||
http.Error(w, "no such escrow", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
|
||||
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
|
||||
writeJSON(w, e)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("games: write response", "err", err)
|
||||
}
|
||||
}
|
||||
210
internal/web/games_hangman.go
Normal file
210
internal/web/games_hangman.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Hangman, played for chips.
|
||||
//
|
||||
// The same shape as the blackjack table: the browser sends intents, the server
|
||||
// holds the state, and the payload carries only what the player is entitled to
|
||||
// see. Here that means the *masked* phrase. The unmasked one is in the engine
|
||||
// state, which is in game_live_hands, which is on this side of the wire — a
|
||||
// phrase sent down and flagged hidden is a phrase read out of devtools, and the
|
||||
// game would be a formality.
|
||||
|
||||
// cellView is one position in the phrase, as the browser draws it.
|
||||
//
|
||||
// Ch is empty while the letter is hidden — not the letter with a flag beside
|
||||
// it. Slot says whether this is a position you'd guess at all: a space or an
|
||||
// exclamation mark is scaffolding, shows from the start, and gets no tile.
|
||||
type cellView struct {
|
||||
Ch string `json:"ch"`
|
||||
Slot bool `json:"slot"`
|
||||
}
|
||||
|
||||
// hangmanView is a game as its player may see it.
|
||||
type hangmanView struct {
|
||||
Tier hangman.Tier `json:"tier"`
|
||||
Cells []cellView `json:"cells"`
|
||||
Tried []string `json:"tried"` // every letter guessed, right or wrong
|
||||
Wrong []string `json:"wrong"` // just the misses — the gallows counts these
|
||||
Lives int `json:"lives"`
|
||||
MaxWrong int `json:"max_wrong"`
|
||||
Multiple float64 `json:"multiple"` // what a win is worth right now
|
||||
Bet int64 `json:"bet"`
|
||||
Stands int64 `json:"stands"` // what the player would actually be paid if they won now
|
||||
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Phrase string `json:"phrase,omitempty"` // only once it's over
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewHangman(g hangman.State) hangmanView {
|
||||
v := hangmanView{
|
||||
Tier: g.Tier,
|
||||
Lives: g.Lives(),
|
||||
MaxWrong: hangman.MaxWrong,
|
||||
Multiple: g.Multiple(),
|
||||
Bet: g.Bet,
|
||||
// What the player would actually collect, rake already taken out. Quoting
|
||||
// the pre-rake figure here would have the felt advertising a payout the
|
||||
// house doesn't hand over.
|
||||
Stands: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
for i, r := range g.Runes {
|
||||
c := cellView{Slot: hangman.Guessable(r)}
|
||||
if i < len(g.Shown) && g.Shown[i] {
|
||||
c.Ch = string(r)
|
||||
}
|
||||
v.Cells = append(v.Cells, c)
|
||||
}
|
||||
for _, r := range g.Tried {
|
||||
v.Tried = append(v.Tried, string(r))
|
||||
}
|
||||
for _, r := range g.Wrong {
|
||||
v.Wrong = append(v.Wrong, string(r))
|
||||
}
|
||||
// The phrase goes over the wire exactly once: when the game is over and it no
|
||||
// longer decides anything.
|
||||
if g.Phase == hangman.PhaseDone {
|
||||
v.Phrase = g.Phrase
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// handleHangmanStart takes the bet and draws a phrase. Same order as a deal:
|
||||
// the chips are staked first, in the same statement that checks they exist, so
|
||||
// two starts fired at once cannot bet the same chip.
|
||||
func (s *Server) handleHangmanStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := hangman.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a length"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: hangman stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
g, evs, err := hangman.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: hangman start", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleHangmanGuess plays one guess: a letter, or the whole phrase.
|
||||
func (s *Server) handleHangmanGuess(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move hangman.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": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: hangman load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameHangman {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||
return
|
||||
}
|
||||
var g hangman.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable hangman game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := hangman.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// A letter already tried is the one illegal move a player makes by
|
||||
// accident rather than by trying it on, so it gets its own answer.
|
||||
msg := "that guess isn't legal here"
|
||||
if errors.Is(err, hangman.ErrAlreadyTried) {
|
||||
msg = "you've already tried that one"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistHangman(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistHangman writes the game back and answers the browser.
|
||||
func (s *Server) persistHangman(w http.ResponseWriter, user string, g hangman.State, evs []hangman.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal hangman", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == hangman.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameHangman, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but
|
||||
// the browser still needs the final board to reveal the phrase onto.
|
||||
if done {
|
||||
hv := viewHangman(g)
|
||||
v.Hangman = &hv
|
||||
}
|
||||
v.HangEvents = evs
|
||||
writeJSON(w, v)
|
||||
}
|
||||
188
internal/web/games_hangman_test.go
Normal file
188
internal/web/games_hangman_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and the
|
||||
// phrase does not leave the server.
|
||||
func TestHangmanStartTakesTheStakeAndKeepsThePhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Hangman == nil {
|
||||
t.Fatal("start returned no game")
|
||||
}
|
||||
if v.Game != gameHangman {
|
||||
t.Errorf("game = %q, want hangman", v.Game)
|
||||
}
|
||||
if v.Hangman.Phrase != "" {
|
||||
t.Fatalf("the phrase was sent to the browser before it was won: %q", v.Hangman.Phrase)
|
||||
}
|
||||
// Nothing is revealed at the start except the scaffolding, and a space is not
|
||||
// a letter you have to earn.
|
||||
for _, c := range v.Hangman.Cells {
|
||||
if c.Slot && c.Ch != "" {
|
||||
t.Fatalf("a letter was face up before it was guessed: %+v", c)
|
||||
}
|
||||
}
|
||||
if v.Hangman.Lives != 6 {
|
||||
t.Errorf("lives = %d, want 6", v.Hangman.Lives)
|
||||
}
|
||||
}
|
||||
|
||||
// A win pays what the felt said it would, and the rake comes out of the winnings.
|
||||
func TestHangmanWinPaysWhatTheFeltQuoted(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
quoted := v.Hangman.Stands
|
||||
|
||||
// The server holds the phrase, so read it out of the live row — which is the
|
||||
// only place it exists — and solve it.
|
||||
phrase := livePhrase(t)
|
||||
v, code := call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": phrase}))
|
||||
if code != 200 {
|
||||
t.Fatalf("solve = %d, want 200", code)
|
||||
}
|
||||
if v.Hangman.Outcome != "solved" {
|
||||
t.Fatalf("outcome = %q, want solved", v.Hangman.Outcome)
|
||||
}
|
||||
// No wrong guesses, so the full 2.6×: 260 gross, 160 profit, 8 rake, 252 back.
|
||||
if v.Hangman.Payout != quoted {
|
||||
t.Errorf("felt quoted %d, house paid %d", quoted, v.Hangman.Payout)
|
||||
}
|
||||
if v.Hangman.Payout != 252 || v.Hangman.Rake != 8 {
|
||||
t.Errorf("payout/rake = %d/%d, want 252/8", v.Hangman.Payout, v.Hangman.Rake)
|
||||
}
|
||||
if got := chipsNow(t); got != 900+252 {
|
||||
t.Errorf("chips = %d, want %d", got, 900+252)
|
||||
}
|
||||
// And the phrase is finally allowed out, now that it decides nothing.
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("a finished game never told the player what the phrase was")
|
||||
}
|
||||
// The game is off the felt.
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||
t.Error("a settled game is still sitting in game_live_hands")
|
||||
}
|
||||
}
|
||||
|
||||
// Six wrong guesses take the stake and nothing more.
|
||||
func TestHangmanHangingCostsExactlyTheStake(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
// Six solves that are certainly wrong — a wrong solve costs a life, same as a
|
||||
// wrong letter, and this needs no knowledge of the phrase.
|
||||
var v tableView
|
||||
for i := 0; i < 6; i++ {
|
||||
v, _ = call(t, s, s.handleHangmanGuess, as(t, s, "reala", "POST", "/api/games/hangman/guess",
|
||||
map[string]string{"solve": "definitely not the phrase at all"}))
|
||||
}
|
||||
if v.Hangman == nil || v.Hangman.Outcome != "hung" {
|
||||
t.Fatalf("outcome = %+v, want hung", v.Hangman)
|
||||
}
|
||||
if v.Hangman.Payout != 0 {
|
||||
t.Errorf("payout = %d, want 0", v.Hangman.Payout)
|
||||
}
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — a loss costs the stake and no more", got)
|
||||
}
|
||||
if v.Hangman.Phrase == "" {
|
||||
t.Error("hung without being told the answer")
|
||||
}
|
||||
}
|
||||
|
||||
// One game at a time, across games: you cannot walk from a hangman into a hand of
|
||||
// blackjack with chips still riding on a phrase.
|
||||
func TestHangmanHoldsTheSeatAgainstBlackjack(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal",
|
||||
map[string]int64{"bet": 100}))
|
||||
if code != 409 {
|
||||
t.Fatalf("dealt blackjack on top of a live hangman: %d, want 409", code)
|
||||
}
|
||||
// And the stake that was refused came back: 1000 - 100 (the hangman) and not a
|
||||
// chip more.
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Errorf("chips = %d, want 900 — the refused deal kept the stake", got)
|
||||
}
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err != nil {
|
||||
t.Errorf("the hangman was evicted by the deal it refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cashing out mid-phrase is refused, for the same reason as mid-hand.
|
||||
func TestCannotCashOutMidPhrase(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "short"}))
|
||||
|
||||
_, code := call(t, s, s.handleCashOut, as(t, s, "reala", "POST", "/api/games/cashout",
|
||||
map[string]int64{"amount": 0}))
|
||||
if code != 409 {
|
||||
t.Fatalf("cash-out mid-phrase = %d, want 409", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A tier the browser made up is refused, and costs nothing.
|
||||
func TestHangmanRefusesAnInventedTier(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
_, code := call(t, s, s.handleHangmanStart, as(t, s, "reala", "POST", "/api/games/hangman/start",
|
||||
map[string]any{"bet": 100, "tier": "impossible"}))
|
||||
if code != 400 {
|
||||
t.Fatalf("start on a made-up tier = %d, want 400", code)
|
||||
}
|
||||
if got := chipsNow(t); got != 1000 {
|
||||
t.Errorf("chips = %d, want 1000 — a refused game must not take a stake", got)
|
||||
}
|
||||
}
|
||||
|
||||
// livePhrase digs the phrase out of the live row. Only a test may do this: it is
|
||||
// reaching past the wire on purpose, to prove the wire doesn't carry it.
|
||||
func livePhrase(t *testing.T) string {
|
||||
t.Helper()
|
||||
live, err := storage.LoadLiveHand(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob := string(live.State)
|
||||
const key = `"phrase":"`
|
||||
i := strings.Index(blob, key)
|
||||
if i < 0 {
|
||||
t.Fatalf("no phrase in the live row: %s", blob)
|
||||
}
|
||||
rest := blob[i+len(key):]
|
||||
j := strings.Index(rest, `"`)
|
||||
if j < 0 {
|
||||
t.Fatal("unterminated phrase in the live row")
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
208
internal/web/games_pages.go
Normal file
208
internal/web/games_pages.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The casino's two pages. Both require a signed-in visitor — there is money in
|
||||
// here, and a player has to be somebody gogobee's ledger can name.
|
||||
//
|
||||
// Neither page renders any game state server-side. The felt is drawn by the
|
||||
// browser from /api/games/table, because a hand is a thing that *happens*: cards
|
||||
// are dealt one at a time and the table plays that back. A server-rendered hand
|
||||
// would arrive fully formed, which is the one thing a card table must never do.
|
||||
|
||||
// gameTeaser is a table that isn't open yet. They're on the lobby because an
|
||||
// empty casino with one game reads as broken, and this reads as early.
|
||||
type gameTeaser struct {
|
||||
Name string
|
||||
Emoji string
|
||||
Blurb string
|
||||
}
|
||||
|
||||
// comingSoon is empty, and that is the point: every game the plan named is now on
|
||||
// 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.
|
||||
var betDenominations = []int64{5, 25, 100, 500}
|
||||
|
||||
// The casino is not called Pete — the news app is Pete's, and this is somewhere
|
||||
// you go. It has two names, and which one is over the door depends on the hour:
|
||||
// the lights come on at six and the place turns into Casino Night Zone until
|
||||
// dawn. Same tables, different room.
|
||||
type room struct {
|
||||
Slug string // drives the palette: html[data-room="…"]
|
||||
Name string // what's on the sign
|
||||
}
|
||||
|
||||
var (
|
||||
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
|
||||
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
|
||||
)
|
||||
|
||||
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
|
||||
// belongs to the neon. The browser re-runs this same rule against its own clock
|
||||
// (games_layout.html), so a player in another timezone sees their own evening —
|
||||
// this server-side pick only exists so the first paint isn't the wrong room.
|
||||
func roomAt(hour int) room {
|
||||
if hour >= 6 && hour < 18 {
|
||||
return roomDay
|
||||
}
|
||||
return roomNight
|
||||
}
|
||||
|
||||
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
|
||||
// language and nothing else — no channels, no weather, no sources, no push.
|
||||
// Giving it its own page struct is what stops the news app's furniture drifting
|
||||
// back in one convenient field at a time.
|
||||
type gamesPage struct {
|
||||
Room room
|
||||
User *SessionUser
|
||||
Cap int64
|
||||
RakePct int
|
||||
Soon []gameTeaser
|
||||
Denominations []int64
|
||||
Tiers []hangman.Tier // hangman's three lengths, and what each pays
|
||||
MaxWrong int
|
||||
Deals []klondike.Tier // solitaire's three deals
|
||||
FullDeck int
|
||||
Quizzes []trivia.Tier // trivia's three difficulties
|
||||
Rungs int // how long the trivia ladder is
|
||||
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.
|
||||
//
|
||||
// It exists so there is exactly one list of them. The dev rig (devcasino_test.go)
|
||||
// has to wire its own mux — New() decides whether the casino exists before the
|
||||
// rig has signed anybody in — and a second copy of this list is a list that
|
||||
// silently stops including the newest game.
|
||||
func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /games", s.handleLobby)
|
||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
||||
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||
mux.HandleFunc("GET /games/uno", s.handleUno)
|
||||
mux.HandleFunc("GET /games/holdem", s.handleHoldem)
|
||||
|
||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
||||
|
||||
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
||||
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
||||
|
||||
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
|
||||
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
|
||||
|
||||
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
|
||||
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
|
||||
|
||||
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||
|
||||
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
||||
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.
|
||||
// Anyone who is signed in but carries a session from before the casino existed
|
||||
// has no username in it, so they get sent through sign-in too — which mints one.
|
||||
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !s.gamesReady() {
|
||||
http.NotFound(w, r)
|
||||
return false
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
|
||||
return true
|
||||
}
|
||||
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) gamesPage(r *http.Request) gamesPage {
|
||||
return gamesPage{
|
||||
Room: roomAt(time.Now().Hour()),
|
||||
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
|
||||
Cap: storage.MaxChipsOnTable,
|
||||
RakePct: int(blackjack.DefaultRules().RakePct * 100),
|
||||
Soon: comingSoon,
|
||||
Denominations: betDenominations,
|
||||
Tiers: hangman.Tiers,
|
||||
MaxWrong: hangman.MaxWrong,
|
||||
Deals: klondike.Tiers,
|
||||
FullDeck: klondike.FullDeck,
|
||||
Quizzes: trivia.Tiers,
|
||||
Rungs: trivia.Rungs,
|
||||
Tables: uno.Tiers,
|
||||
Stakes: holdem.Tiers,
|
||||
MaxBots: holdem.MaxBots,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "games", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "blackjack", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "hangman", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "solitaire", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "trivia", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
655
internal/web/games_play.go
Normal file
655
internal/web/games_play.go
Normal file
@@ -0,0 +1,655 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/cards"
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/holdem"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The table, as a browser sees it.
|
||||
//
|
||||
// Everything here is server-authoritative. The browser sends intents — deal me
|
||||
// in, hit, stand — and gets back a *view*: the cards it is entitled to see and
|
||||
// nothing else. The shoe stays in game_live_hands, on this side of the wire.
|
||||
// That is not belt-and-braces, it is the whole reason the engines are Go: a game
|
||||
// with money on it cannot trust a client-reported result, and a client that
|
||||
// holds the deck can read the next card.
|
||||
//
|
||||
// The stake leaves the player's stack *before* the hand is dealt, and comes back
|
||||
// only through the engine's own payout. So a hand that crashes halfway costs the
|
||||
// player their bet and nothing more, and a hand that Pete restarts through is
|
||||
// still sitting there when they come back.
|
||||
|
||||
// gamesReady reports whether the casino can actually run: it needs sign-in (a
|
||||
// player has to be someone) and a Matrix server name (that someone has to exist
|
||||
// in gogobee's ledger).
|
||||
func (s *Server) gamesReady() bool {
|
||||
return s.cfg.Games.Enabled && s.auth != nil && s.cfg.Games.MatrixServer != ""
|
||||
}
|
||||
|
||||
// player resolves the signed-in visitor to their Matrix id, or writes the
|
||||
// failure. An empty id means a session from before games existed, which carries
|
||||
// no username: sending them back through sign-in mints one.
|
||||
func (s *Server) player(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if !s.gamesReady() {
|
||||
http.NotFound(w, r)
|
||||
return "", false
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
writeJSONStatus(w, http.StatusUnauthorized, map[string]string{"error": "sign in to play"})
|
||||
return "", false
|
||||
}
|
||||
mx := u.MatrixUser(s.cfg.Games.MatrixServer)
|
||||
if mx == "" {
|
||||
writeJSONStatus(w, http.StatusForbidden, map[string]string{
|
||||
"error": "your session predates the casino — sign out and back in",
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
return mx, true
|
||||
}
|
||||
|
||||
// ---- what the browser is allowed to see -----------------------------------
|
||||
|
||||
// cardView is one card, pre-rendered. The browser draws faces, not logic: it
|
||||
// gets the glyph and the colour rather than a rank it has to map itself.
|
||||
type cardView struct {
|
||||
Label string `json:"label"` // "A♠"
|
||||
Rank string `json:"rank"` // "A"
|
||||
Suit string `json:"suit"` // "♠"
|
||||
Red bool `json:"red"`
|
||||
}
|
||||
|
||||
func viewCard(c cards.Card) cardView {
|
||||
label := c.String()
|
||||
// String() renders rank then a three-byte suit glyph, except for a card that
|
||||
// isn't one ("??"), which has no glyph to split off.
|
||||
if len(label) <= len("♠") {
|
||||
return cardView{Label: label, Rank: label}
|
||||
}
|
||||
cut := len(label) - len("♠")
|
||||
return cardView{Label: label, Rank: label[:cut], Suit: label[cut:], Red: c.Red()}
|
||||
}
|
||||
|
||||
// handView is a blackjack hand as its player may see it. While they are still
|
||||
// acting, the dealer's hole card is *absent* — not sent and flagged hidden, but
|
||||
// genuinely not in the payload. A field the browser is told to ignore is a field
|
||||
// somebody reads in devtools.
|
||||
type handView struct {
|
||||
Phase string `json:"phase"`
|
||||
Bet int64 `json:"bet"`
|
||||
Player []cardView `json:"player"`
|
||||
Dealer []cardView `json:"dealer"`
|
||||
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
||||
Total int `json:"total"`
|
||||
Soft bool `json:"soft"`
|
||||
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
Double bool `json:"can_double"`
|
||||
}
|
||||
|
||||
func viewHand(st blackjack.State) handView {
|
||||
v := handView{
|
||||
Phase: string(st.Phase),
|
||||
Bet: st.Bet,
|
||||
Outcome: string(st.Outcome),
|
||||
Payout: st.Payout,
|
||||
Rake: st.Rake,
|
||||
Net: st.Net(),
|
||||
Double: st.CanDouble(),
|
||||
}
|
||||
for _, c := range st.Player {
|
||||
v.Player = append(v.Player, viewCard(c))
|
||||
}
|
||||
v.Total, v.Soft = blackjack.HandValue(st.Player)
|
||||
|
||||
dealer := st.Dealer
|
||||
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
|
||||
dealer = dealer[:1] // the hole card is the dealer's business until it isn't
|
||||
v.Hole = true
|
||||
}
|
||||
for _, c := range dealer {
|
||||
v.Dealer = append(v.Dealer, viewCard(c))
|
||||
}
|
||||
v.DTotal, _ = blackjack.HandValue(dealer)
|
||||
return v
|
||||
}
|
||||
|
||||
// eventView is the dealing script. The engine emits one event per card off the
|
||||
// shoe, in order, and the table animates them one at a time — which is why the
|
||||
// events go over the wire at all rather than the browser diffing two states.
|
||||
type eventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Card *cardView `json:"card,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// viewEvents renders the engine's events for the browser, dropping the cards it
|
||||
// is not yet allowed to see: the dealer's second card is dealt face-down, and
|
||||
// only the "reveal" event turns it over.
|
||||
func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
|
||||
out := make([]eventView, 0, len(evs))
|
||||
dealerCards := 0
|
||||
for _, e := range evs {
|
||||
v := eventView{Kind: e.Kind, Text: e.Text}
|
||||
if e.Card != nil {
|
||||
c := viewCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
if e.Kind == "dealer_card" {
|
||||
dealerCards++
|
||||
// The hole card, while the hand is still the player's to play: send
|
||||
// the event so the table deals a face-down card, but not the face.
|
||||
if dealerCards == 2 && phase == blackjack.PhasePlayer {
|
||||
v.Card = nil
|
||||
v.Kind = "dealer_hole"
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tableView is the whole page state: the money, and whatever game is in progress.
|
||||
//
|
||||
// A player is in at most one game at a time — game_live_hands is keyed on the
|
||||
// player, so the primary key enforces it — and Game says which. Each game gets
|
||||
// its own field rather than a shared blob, because a hangman phrase and a
|
||||
// blackjack shoe have nothing in common and pretending otherwise would mean a
|
||||
// browser that has to guess what it's holding.
|
||||
type tableView struct {
|
||||
Chips int64 `json:"chips"`
|
||||
Pending int64 `json:"pending"` // buy-ins gogobee hasn't answered yet
|
||||
Euros float64 `json:"euros"` // advisory, and up to a couple of minutes stale
|
||||
Cap int64 `json:"cap"`
|
||||
|
||||
Game string `json:"game,omitempty"` // "blackjack" | "hangman" | "solitaire", if one is live
|
||||
|
||||
Hand *handView `json:"hand,omitempty"` // blackjack
|
||||
Events []eventView `json:"events,omitempty"` // blackjack, only on a move
|
||||
|
||||
Hangman *hangmanView `json:"hangman,omitempty"`
|
||||
HangEvents []hangman.Event `json:"hang_events,omitempty"`
|
||||
|
||||
Solitaire *solitaireView `json:"solitaire,omitempty"`
|
||||
SolEvents []solEventView `json:"sol_events,omitempty"`
|
||||
|
||||
Trivia *triviaView `json:"trivia,omitempty"`
|
||||
TrivEvents []trivia.Event `json:"triv_events,omitempty"`
|
||||
|
||||
Uno *unoView `json:"uno,omitempty"`
|
||||
UnoEvents []unoEventView `json:"uno_events,omitempty"`
|
||||
|
||||
Holdem *holdemView `json:"holdem,omitempty"`
|
||||
HoldemEvents []holdemEventView `json:"holdem_events,omitempty"`
|
||||
|
||||
Rake float64 `json:"rake_pct"`
|
||||
}
|
||||
|
||||
// table reads the player's money and any game in progress.
|
||||
func (s *Server) table(user string) (tableView, error) {
|
||||
st, err := storage.Chips(user)
|
||||
if err != nil {
|
||||
return tableView{}, err
|
||||
}
|
||||
v := tableView{
|
||||
Chips: st.Chips,
|
||||
Pending: st.Pending,
|
||||
Euros: st.EuroBalance,
|
||||
Cap: storage.MaxChipsOnTable,
|
||||
Rake: blackjack.DefaultRules().RakePct,
|
||||
}
|
||||
live, err := storage.LoadLiveHand(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
return v, nil
|
||||
}
|
||||
if err != nil {
|
||||
return tableView{}, err
|
||||
}
|
||||
|
||||
// Dispatch on the game the row says it is. Unmarshalling a hangman state into
|
||||
// a blackjack one would not fail — JSON is happy to fill nothing in — it would
|
||||
// just quietly produce an empty hand, which is the worst of both.
|
||||
v.Game = live.Game
|
||||
switch live.Game {
|
||||
case gameBlackjack:
|
||||
var hand blackjack.State
|
||||
if err := json.Unmarshal(live.State, &hand); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
hv := viewHand(hand)
|
||||
v.Hand = &hv
|
||||
case gameHangman:
|
||||
var g hangman.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
hv := viewHangman(g)
|
||||
v.Hangman = &hv
|
||||
case gameSolitaire:
|
||||
var g klondike.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
sv := viewSolitaire(g)
|
||||
v.Solitaire = &sv
|
||||
case gameTrivia:
|
||||
var g trivia.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
// The clock does not stop for a reload: Left is measured from the AskedAt
|
||||
// the server stamped, so a player who refreshes to buy themselves a fresh
|
||||
// twenty seconds finds the countdown exactly where they left it.
|
||||
tv := viewTrivia(g, time.Now())
|
||||
v.Trivia = &tv
|
||||
case gameUno:
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
uv := viewUno(g)
|
||||
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:
|
||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// dropUnreadable throws away a live game nobody can play. Rather than wedge the
|
||||
// player out of the casino forever, it goes, and their stake with it — which is
|
||||
// why it is logged loudly. The alternative is a player who can never be dealt
|
||||
// another hand because an old one won't parse.
|
||||
func (s *Server) dropUnreadable(user string, v tableView, err error) (tableView, error) {
|
||||
slog.Error("games: unreadable live game, discarding", "user", user, "err", err)
|
||||
_ = storage.ClearLiveHand(user)
|
||||
v.Game = ""
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
|
||||
// handleTable is the page's poll: chips, euros, and whatever hand is on the felt.
|
||||
func (s *Server) handleTable(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
// amountBody is {amount: n} — chips, which are euros.
|
||||
type amountBody struct {
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(v)
|
||||
}
|
||||
|
||||
// handleBuyIn opens a buy-in. It creates no chips: it writes an escrow row, and
|
||||
// gogobee decides — up to three seconds later — whether the player could afford
|
||||
// it. The browser watches `pending` fall to zero to know how it went.
|
||||
func (s *Server) handleBuyIn(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req amountBody
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
e, err := storage.RequestBuyIn(user, req.Amount)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBadAmount):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "buy in for something more than nothing"})
|
||||
return
|
||||
case errors.Is(err, storage.ErrOverTableCap):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "that would put more than the table cap in front of you",
|
||||
})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("games: buy-in", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
storage.Touch(user)
|
||||
slog.Info("games: buy-in requested", "user", user, "amount", e.Amount, "guid", e.GUID)
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: table after buy-in", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
// handleCashOut sends chips back across the border. The chips are destroyed
|
||||
// here and now — see storage.RequestCashOut for why that has to happen before
|
||||
// gogobee has said anything — so the response already shows an empty stack. An
|
||||
// amount of zero or less means "all of it", which is what the button does.
|
||||
func (s *Server) handleCashOut(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req amountBody
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// You cannot walk away from a hand you have chips riding on. The stake is
|
||||
// already off the stack, so this isn't about the money — it's that a hand
|
||||
// left half-played would settle into a session that no longer exists.
|
||||
if _, err := storage.LoadLiveHand(user); err == nil {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand first"})
|
||||
return
|
||||
} else if !errors.Is(err, storage.ErrNoLiveHand) {
|
||||
slog.Error("games: cash-out live-hand check", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
if amount <= 0 {
|
||||
st, err := storage.Chips(user)
|
||||
if err != nil {
|
||||
slog.Error("games: cash-out", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
amount = st.Chips
|
||||
}
|
||||
|
||||
e, err := storage.RequestCashOut(user, amount)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBadAmount), errors.Is(err, storage.ErrInsufficientChips):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("games: cash-out", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("games: cash-out requested", "user", user, "amount", e.Amount, "guid", e.GUID)
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: table after cash-out", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
// ---- blackjack ------------------------------------------------------------
|
||||
|
||||
// handleDeal takes the bet and deals. The order matters: chips are staked first,
|
||||
// in the same statement that checks they exist, so two deals fired at once
|
||||
// cannot bet the same chip. Only then is a hand dealt.
|
||||
func (s *Server) handleDeal(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
st, evs, err := blackjack.New(req.Bet, blackjack.DefaultRules(), rng)
|
||||
if err != nil {
|
||||
// The hand never happened, so the stake never should have left. Give it back.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: deal", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persist(w, user, st, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleMove plays one move of the hand in progress.
|
||||
func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Move string `json:"move"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); 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": "no hand in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: load hand", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var st blackjack.State
|
||||
if err := json.Unmarshal(live.State, &st); err != nil {
|
||||
slog.Error("games: unreadable live hand", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
move := blackjack.Move(req.Move)
|
||||
|
||||
// A double doubles the stake, so the extra chips have to be taken before the
|
||||
// move is applied — and if they aren't there, the move simply isn't legal.
|
||||
// Take them first: if the engine then refuses the move, they go straight back.
|
||||
doubled := false
|
||||
if move == blackjack.Double {
|
||||
if !st.CanDouble() {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
|
||||
return
|
||||
}
|
||||
if err := storage.Stake(user, st.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: stake double", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
doubled = true
|
||||
}
|
||||
|
||||
next, evs, err := blackjack.ApplyMove(st, move)
|
||||
if err != nil {
|
||||
if doubled {
|
||||
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
|
||||
return
|
||||
}
|
||||
s.persist(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// The games a live row can be. They're the storage key, so they're constants:
|
||||
// a typo here is a game nobody can ever load again.
|
||||
const (
|
||||
gameBlackjack = "blackjack"
|
||||
gameHangman = "hangman"
|
||||
gameSolitaire = "solitaire"
|
||||
gameTrivia = "trivia"
|
||||
gameUno = "uno"
|
||||
gameHoldem = "holdem"
|
||||
)
|
||||
|
||||
// finished is what commit needs to know about a game it's writing back: enough
|
||||
// to settle it, and nothing about how it's played. Both engines produce one.
|
||||
type finished struct {
|
||||
Game string
|
||||
Blob []byte // the engine's whole state, shoe or phrase and all
|
||||
Bet int64
|
||||
Payout int64
|
||||
Rake int64
|
||||
Outcome string
|
||||
Done bool
|
||||
Seed1 uint64
|
||||
Seed2 uint64
|
||||
Fresh bool // a game just started, which is the one write that may be refused
|
||||
}
|
||||
|
||||
// commit writes a game back and settles it if it's over. It is the money path,
|
||||
// and both games go through it so that neither has to re-derive an ordering
|
||||
// that took a while to get right.
|
||||
//
|
||||
// It returns the table as it now stands. ok is false when it has already
|
||||
// written an error response and the caller must simply return.
|
||||
func (s *Server) commit(w http.ResponseWriter, user string, f finished) (tableView, bool) {
|
||||
// Seat the game before doing anything else with it — even one that is already
|
||||
// over, because a blackjack natural settles the instant it's dealt. The insert
|
||||
// is what enforces one game at a time, and it has to happen for *every* new
|
||||
// one: a natural dealt on top of a game already in progress would otherwise
|
||||
// settle, clear the felt, and take the other game's stake down with it.
|
||||
live := storage.LiveHand{Game: f.Game, State: f.Blob, Seed1: f.Seed1, Seed2: f.Seed2}
|
||||
save := storage.SaveLiveHand
|
||||
if f.Fresh {
|
||||
save = storage.StartLiveHand
|
||||
}
|
||||
if err := save(user, live); err != nil {
|
||||
if errors.Is(err, storage.ErrHandInProgress) {
|
||||
// Somebody was already sitting here. This game was never seated, so the
|
||||
// chips it staked go back: the player is in one game, not two.
|
||||
_ = storage.Award(user, f.Bet)
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're already in a game"})
|
||||
return tableView{}, false
|
||||
}
|
||||
slog.Error("games: save game", "user", user, "game", f.Game, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return tableView{}, false
|
||||
}
|
||||
|
||||
if f.Done {
|
||||
// Pay first, then clear. If Pete dies between the two, the player has been
|
||||
// paid and the worst case is a settled game still showing on the felt —
|
||||
// which reads as done and can be cleared. The other order loses them a win.
|
||||
if err := storage.Award(user, f.Payout); err != nil {
|
||||
slog.Error("games: award", "user", user, "payout", f.Payout, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return tableView{}, false
|
||||
}
|
||||
if err := storage.RecordHand(storage.Hand{
|
||||
MatrixUser: user, Game: f.Game,
|
||||
Bet: f.Bet, Payout: f.Payout, Rake: f.Rake,
|
||||
Outcome: f.Outcome, Seed1: f.Seed1, Seed2: f.Seed2,
|
||||
}); err != nil {
|
||||
slog.Error("games: record hand", "user", user, "err", err) // audit only; don't fail the player's game
|
||||
}
|
||||
if err := storage.ClearLiveHand(user); err != nil {
|
||||
slog.Error("games: clear game", "user", user, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
storage.Touch(user)
|
||||
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return tableView{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// persist writes a blackjack hand back and answers the browser.
|
||||
func (s *Server) persist(w http.ResponseWriter, user string, st blackjack.State, evs []blackjack.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal hand", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := st.Phase == blackjack.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameBlackjack, Blob: blob,
|
||||
Bet: st.Bet, Payout: st.Payout, Rake: st.Rake,
|
||||
Outcome: string(st.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A settled hand is gone from storage, so the table view has no hand to show —
|
||||
// but the browser still needs the final cards to animate the reveal onto.
|
||||
if done {
|
||||
hv := viewHand(st)
|
||||
v.Hand = &hv
|
||||
}
|
||||
v.Events = viewEvents(evs, st.Phase)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
// newSeeds mints the shoe's seed. It goes in the audit log, so a hand somebody
|
||||
// disputes can be dealt again exactly as it fell.
|
||||
func newSeeds() (uint64, uint64) {
|
||||
return rand.Uint64(), uint64(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("games: write response", "err", err)
|
||||
}
|
||||
}
|
||||
275
internal/web/games_play_test.go
Normal file
275
internal/web/games_play_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
const testPlayer = "@reala:parodia.dev"
|
||||
|
||||
// newCasino is a server with the tables open and one signed-in player. The
|
||||
// Authenticator is built by hand rather than through OIDC discovery: sign-in is
|
||||
// a network call, and none of what's under test is about the handshake.
|
||||
func newCasino(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"}
|
||||
return s
|
||||
}
|
||||
|
||||
// as returns a request carrying the signed session of the given username. An
|
||||
// empty username is the pre-casino session: signed in, but nobody the economy
|
||||
// can name.
|
||||
func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest(method, path, &buf)
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs one request against a handler and decodes the table view.
|
||||
func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
h(w, r)
|
||||
var v tableView
|
||||
if w.Code == 200 {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||
t.Fatalf("decode table: %v (body %q)", err, w.Body.String())
|
||||
}
|
||||
}
|
||||
return v, w.Code
|
||||
}
|
||||
|
||||
// fund puts chips in front of the player the way the border really does it.
|
||||
func fund(t *testing.T, chips int64) {
|
||||
t.Helper()
|
||||
e, err := storage.RequestBuyIn(testPlayer, chips)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chipsNow(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
st, err := storage.Chips(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Chips
|
||||
}
|
||||
|
||||
// TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get
|
||||
// wrong: the bet leaves the stack, and the dealer's second card does not leave
|
||||
// the server.
|
||||
func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100}))
|
||||
if code != 200 {
|
||||
t.Fatalf("deal = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Hand == nil {
|
||||
t.Fatal("deal returned no hand")
|
||||
}
|
||||
if len(v.Hand.Player) != 2 {
|
||||
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Player))
|
||||
}
|
||||
|
||||
// A natural settles on the spot and legitimately shows both dealer cards.
|
||||
if v.Hand.Phase == "done" {
|
||||
return
|
||||
}
|
||||
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
|
||||
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
|
||||
len(v.Hand.Dealer), v.Hand.Hole)
|
||||
}
|
||||
// And it isn't smuggled out in the dealing script either.
|
||||
for _, e := range v.Events {
|
||||
if e.Kind == "dealer_hole" && e.Card != nil {
|
||||
t.Fatal("the hole card's face went to the browser in the events")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips
|
||||
// moved by exactly what the engine said they did — and that the hand left the
|
||||
// felt and landed in the audit log.
|
||||
func TestHandSettlesIntoTheChipStack(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||
for v.Hand != nil && v.Hand.Phase == "player" {
|
||||
v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"}))
|
||||
}
|
||||
if v.Hand == nil || v.Hand.Phase != "done" {
|
||||
t.Fatalf("hand didn't finish: %+v", v.Hand)
|
||||
}
|
||||
|
||||
// 1000, minus the stake, plus whatever came back.
|
||||
want := int64(1000) - 100 + v.Hand.Payout
|
||||
if got := chipsNow(t); got != want {
|
||||
t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome)
|
||||
}
|
||||
if _, err := storage.LoadLiveHand(testPlayer); err == nil {
|
||||
t.Fatal("a settled hand is still sitting on the felt")
|
||||
}
|
||||
if v.Hand.Outcome == "" {
|
||||
t.Fatal("a finished hand with no outcome")
|
||||
}
|
||||
|
||||
// The rake only ever comes out of winnings.
|
||||
if v.Hand.Outcome == "push" && v.Hand.Payout != 100 {
|
||||
t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout)
|
||||
}
|
||||
if v.Hand.Net < 0 && v.Hand.Rake != 0 {
|
||||
t.Fatalf("a losing hand was raked %d", v.Hand.Rake)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOneHandAtATime is the double-click: a second Deal must not overwrite the
|
||||
// hand the first one is paying for, and must not keep the chips it staked.
|
||||
func TestOneHandAtATime(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||
if first.Hand != nil && first.Hand.Phase == "done" {
|
||||
t.Skip("dealt a natural; there is no live hand to protect")
|
||||
}
|
||||
before := chipsNow(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("second deal = %d, want 409", w.Code)
|
||||
}
|
||||
if got := chipsNow(t); got != before {
|
||||
t.Fatalf("the refused deal cost the player %d chips", before-got)
|
||||
}
|
||||
// The original hand is untouched.
|
||||
live, err := storage.LoadLiveHand(testPlayer)
|
||||
if err != nil {
|
||||
t.Fatalf("the live hand went missing: %v", err)
|
||||
}
|
||||
var st struct {
|
||||
Player []struct{} `json:"player"`
|
||||
}
|
||||
if err := json.Unmarshal(live.State, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Player) != len(first.Hand.Player) {
|
||||
t.Fatal("the refused deal replaced the hand in progress")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCannotCashOutMidHand — the stake is on the table, so the session it would
|
||||
// settle into cannot be closed underneath it.
|
||||
func TestCannotCashOutMidHand(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||
if v.Hand != nil && v.Hand.Phase == "done" {
|
||||
t.Skip("dealt a natural; nothing is in progress")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0}))
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("cash-out mid-hand = %d, want 409", w.Code)
|
||||
}
|
||||
if got := chipsNow(t); got != 900 {
|
||||
t.Fatalf("the refused cash-out moved chips: %d, want 900", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is
|
||||
// refused, and refusing it must not quietly pocket the raise.
|
||||
func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 100)
|
||||
|
||||
v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100}))
|
||||
if v.Hand == nil || v.Hand.Phase != "player" {
|
||||
t.Skip("no live hand to double on")
|
||||
}
|
||||
if chipsNow(t) != 0 {
|
||||
t.Fatal("test wants a player with nothing left to raise with")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"}))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("broke double = %d, want 400", w.Code)
|
||||
}
|
||||
if got := chipsNow(t); got != 0 {
|
||||
t.Fatalf("chips = %d after a refused double, want 0", got)
|
||||
}
|
||||
// And the hand is still there, still doubleable once they can afford it.
|
||||
after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil))
|
||||
if code != 200 || after.Hand == nil || !after.Hand.Double {
|
||||
t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted
|
||||
// before the casino existed carries no username, so it can't be mapped to a
|
||||
// Matrix id — that's a 403, and the fix is to sign in again.
|
||||
func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("session with no username = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can
|
||||
// be named to gogobee, so the tables 404 rather than dealing hands whose money
|
||||
// has nowhere to go.
|
||||
func TestCasinoIsShutWithoutAServerName(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
s.cfg.Games.MatrixServer = ""
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("table with no server name = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
51
internal/web/games_render_test.go
Normal file
51
internal/web/games_render_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every page the casino routes to must be in the games template set. This is not
|
||||
// a fussy test: uno shipped with its handler wired, its engine tested and its
|
||||
// template written, and every visit to the table answered "unknown page" with a
|
||||
// 500 — because the page was never added to the list server.go parses. Nothing
|
||||
// else caught it, since the other tests call the handlers straight and never go
|
||||
// through render(). Add a game, add it here.
|
||||
func TestEveryCasinoPageRenders(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
|
||||
pages := []string{
|
||||
"/games",
|
||||
"/games/blackjack",
|
||||
"/games/hangman",
|
||||
"/games/solitaire",
|
||||
"/games/trivia",
|
||||
"/games/uno",
|
||||
"/games/holdem",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
s.casinoRoutes(mux)
|
||||
|
||||
for _, path := range pages {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, as(t, s, "reala", "GET", path, nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200 (body: %s)", path, w.Code, strings.TrimSpace(w.Body.String()))
|
||||
}
|
||||
// render() writes the 500 as a body and a page that fails halfway
|
||||
// through still comes back 200, so look at what was actually served.
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "unknown page") {
|
||||
t.Fatalf("GET %s served the unknown-page error: the template is missing from the games set in server.go", path)
|
||||
}
|
||||
if !strings.Contains(body, "</html>") {
|
||||
t.Fatalf("GET %s did not render a whole page (%d bytes) — the template blew up mid-render", path, len(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
286
internal/web/games_solitaire.go
Normal file
286
internal/web/games_solitaire.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/cards"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Solitaire, played for chips. Vegas scoring: you buy the deck, and every card
|
||||
// you get home pays a slice of it back.
|
||||
//
|
||||
// The withheld information here is bigger than blackjack's single hole card —
|
||||
// it's the stock and every face-down card in the tableau, which between them are
|
||||
// most of the deck. So the view sends *counts* for both: a column says how many
|
||||
// cards are face-down under it, never which. A browser that held the stock would
|
||||
// be a browser that knows whether the next pull is worth taking, and this game is
|
||||
// nothing but that decision, repeated.
|
||||
//
|
||||
// The events, on the other hand, need no filtering at all, and that's worth
|
||||
// saying out loud because blackjack's do. Every card a klondike event carries is
|
||||
// a card the move itself just turned face up: the draw puts cards in the waste,
|
||||
// the flip turns a column's top card over, a move carries cards that were already
|
||||
// face up. There is no event here that mentions a card the player isn't now
|
||||
// looking at.
|
||||
|
||||
// solPileView is one tableau column: how deep the face-down stack is, and the
|
||||
// run sitting face up on top of it.
|
||||
type solPileView struct {
|
||||
Down int `json:"down"`
|
||||
Up []cardView `json:"up"`
|
||||
}
|
||||
|
||||
// solFoundView is one foundation. Only the top card matters — it's the only one
|
||||
// that can be played back off — so it's the only one sent, with a count for the
|
||||
// height of the pile.
|
||||
type solFoundView struct {
|
||||
Suit string `json:"suit"` // the glyph, so the empty pile can show what it wants
|
||||
Red bool `json:"red"`
|
||||
N int `json:"n"`
|
||||
Top *cardView `json:"top,omitempty"`
|
||||
}
|
||||
|
||||
// solitaireView is a board as its player may see it.
|
||||
type solitaireView struct {
|
||||
Tier klondike.Tier `json:"tier"`
|
||||
|
||||
Stock int `json:"stock"` // how many cards are left in it, not which
|
||||
Waste []cardView `json:"waste"` // the top few, in the order they were turned
|
||||
WasteN int `json:"waste_n"`
|
||||
Table []solPileView `json:"table"`
|
||||
Found []solFoundView `json:"found"`
|
||||
|
||||
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
||||
Moves int `json:"moves"`
|
||||
CanAuto bool `json:"can_auto"`
|
||||
|
||||
Home int `json:"home"` // cards on the foundations
|
||||
PerCard float64 `json:"per_card"` // what one more is worth
|
||||
BreakEven int `json:"break_even"` // how many gets you square with the house
|
||||
Bet int64 `json:"bet"`
|
||||
Stands int64 `json:"stands"` // what cashing out right now actually pays
|
||||
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
// wasteShown is how much of the waste the felt fans out. Three, because that is
|
||||
// what a three-card draw puts down and the rest of the pile is just a pile.
|
||||
const wasteShown = 3
|
||||
|
||||
func viewSolitaire(g klondike.State) solitaireView {
|
||||
v := solitaireView{
|
||||
Tier: g.Tier,
|
||||
Stock: len(g.Stock),
|
||||
WasteN: len(g.Waste),
|
||||
Passes: g.PassesLeft(),
|
||||
Moves: g.Moves,
|
||||
CanAuto: g.CanAuto(),
|
||||
Home: g.Home(),
|
||||
PerCard: g.PerCard(),
|
||||
BreakEven: g.Tier.BreakEven(),
|
||||
Bet: g.Bet,
|
||||
// What cashing out right now would actually land on the stack, rake already
|
||||
// out of it. The pre-rake figure would have the felt advertising a number
|
||||
// the house doesn't hand over.
|
||||
Stands: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
|
||||
from := len(g.Waste) - wasteShown
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
for _, c := range g.Waste[from:] {
|
||||
v.Waste = append(v.Waste, viewCard(c))
|
||||
}
|
||||
|
||||
v.Table = make([]solPileView, klondike.Piles)
|
||||
for i, p := range g.Table {
|
||||
v.Table[i] = solPileView{Down: len(p.Down)}
|
||||
for _, c := range p.Up {
|
||||
v.Table[i].Up = append(v.Table[i].Up, viewCard(c))
|
||||
}
|
||||
}
|
||||
|
||||
v.Found = make([]solFoundView, klondike.Foundations)
|
||||
for i, f := range g.Found {
|
||||
suit := cards.Suit(i)
|
||||
fv := solFoundView{Suit: suit.String(), Red: suit == cards.Hearts || suit == cards.Diamonds, N: len(f)}
|
||||
if len(f) > 0 {
|
||||
top := viewCard(f[len(f)-1])
|
||||
fv.Top = &top
|
||||
}
|
||||
v.Found[i] = fv
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// solEventView is one thing the table animates. See the note at the top: unlike
|
||||
// blackjack's, these need nothing stripped out of them.
|
||||
type solEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Cards []cardView `json:"cards,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Home int `json:"home"`
|
||||
Pays int64 `json:"pays"`
|
||||
}
|
||||
|
||||
func viewSolEvents(evs []klondike.Event) []solEventView {
|
||||
out := make([]solEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := solEventView{Kind: e.Kind, From: e.From, To: e.To, Home: e.Home, Pays: e.Pays}
|
||||
for _, c := range e.Cards {
|
||||
v.Cards = append(v.Cards, viewCard(c))
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleSolitaireStart takes the stake and deals the board. Same order as a
|
||||
// blackjack deal: the chips are staked first, in the same statement that checks
|
||||
// they exist, so two starts fired at once cannot buy the same deck twice.
|
||||
func (s *Server) handleSolitaireStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := klondike.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a deal"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that deck"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: solitaire stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
g, evs, err := klondike.New(req.Bet, tier, blackjack.DefaultRules().RakePct, rng)
|
||||
if err != nil {
|
||||
// The board never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: solitaire deal", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistSolitaire(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// solitaireErrors are the illegal moves a player makes by playing, rather than
|
||||
// by tampering. Each gets said back to them in words, because "that move isn't
|
||||
// legal" over a board with 60 legal-looking targets on it is not an answer.
|
||||
var solitaireErrors = map[error]string{
|
||||
klondike.ErrWontGo: "that card doesn't go there",
|
||||
klondike.ErrNotASequence: "you can only lift a run that goes down in rank and alternates colour",
|
||||
klondike.ErrEmptyPile: "there's nothing there",
|
||||
klondike.ErrNoDraw: "the stock is empty",
|
||||
klondike.ErrNoPasses: "that was your last pass through the stock",
|
||||
klondike.ErrNothingHome: "nothing can go home right now",
|
||||
klondike.ErrGameOver: "that board is finished",
|
||||
}
|
||||
|
||||
// handleSolitaireMove plays one move: a draw, a card moved, a card sent home, an
|
||||
// auto-finish, or cashing the board in.
|
||||
func (s *Server) handleSolitaireMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move klondike.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": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: solitaire load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameSolitaire {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the hand you're in first"})
|
||||
return
|
||||
}
|
||||
var g klondike.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable solitaire board", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := klondike.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
msg, known := solitaireErrors[err]
|
||||
if !known {
|
||||
msg = "that move isn't legal here"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistSolitaire(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistSolitaire writes the board back and answers the browser.
|
||||
func (s *Server) persistSolitaire(w http.ResponseWriter, user string, g klondike.State, evs []klondike.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal solitaire", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == klondike.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameSolitaire, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished board is gone from storage, so the table has none to show — but
|
||||
// the browser still needs the final one to animate the last cards onto.
|
||||
if done {
|
||||
sv := viewSolitaire(g)
|
||||
v.Solitaire = &sv
|
||||
}
|
||||
v.SolEvents = viewSolEvents(evs)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
248
internal/web/games_test.go
Normal file
248
internal/web/games_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// pollEscrow runs one gogobee poll.
|
||||
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEscrowPending(w, req)
|
||||
if w.Code != 200 {
|
||||
return nil, w.Code
|
||||
}
|
||||
var out []storage.Escrow
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
|
||||
}
|
||||
return out, w.Code
|
||||
}
|
||||
|
||||
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(escrowGUID{GUID: guid})
|
||||
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEscrowClaim(w, req)
|
||||
var e storage.Escrow
|
||||
if w.Code == 200 {
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||||
}
|
||||
return e, w.Code
|
||||
}
|
||||
|
||||
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(v)
|
||||
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEscrowSettled(w, req)
|
||||
var e storage.Escrow
|
||||
if w.Code == 200 {
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
||||
}
|
||||
return e, w.Code
|
||||
}
|
||||
|
||||
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
|
||||
// chips, watch gogobee pick the row up, and have the chips appear only once the
|
||||
// euros are confirmed gone.
|
||||
func TestEscrowBuyInRoundTrip(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
const user = "@reala:parodia.dev"
|
||||
|
||||
req, err := storage.RequestBuyIn(user, 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No chips yet. The euros are still gogobee's.
|
||||
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
|
||||
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
|
||||
}
|
||||
|
||||
pending, code := pollEscrow(t, s, "tok")
|
||||
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
|
||||
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
|
||||
}
|
||||
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
|
||||
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
|
||||
}
|
||||
|
||||
claimed, code := claimEscrow(t, s, "tok", req.GUID)
|
||||
if code != 200 || claimed.State != storage.EscrowClaimed {
|
||||
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
|
||||
}
|
||||
|
||||
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
|
||||
if code != 200 || e.State != storage.EscrowFunded {
|
||||
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
|
||||
}
|
||||
|
||||
st, _ := storage.Chips(user)
|
||||
if st.Chips != 500 || st.Pending != 0 {
|
||||
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
|
||||
}
|
||||
if st.EuroBalance != 1500 {
|
||||
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
|
||||
}
|
||||
|
||||
// Settled rows are done. A later poll must not offer it again, or gogobee
|
||||
// would debit the player a second time.
|
||||
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
|
||||
t.Fatalf("settled row still pending: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
|
||||
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
|
||||
// and only the first one may create chips.
|
||||
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
const user = "@replay:parodia.dev"
|
||||
|
||||
req, err := storage.RequestBuyIn(user, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
||||
t.Fatalf("claim = %d", code)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
|
||||
if code != 200 || e.State != storage.EscrowFunded {
|
||||
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
|
||||
}
|
||||
}
|
||||
|
||||
if st, _ := storage.Chips(user); st.Chips != 200 {
|
||||
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
|
||||
// took no euros, so Pete must create no chips, and the pending amount has to
|
||||
// clear or it would eat into the table cap forever.
|
||||
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
const user = "@broke:parodia.dev"
|
||||
|
||||
req, err := storage.RequestBuyIn(user, 9000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
||||
t.Fatalf("claim = %d", code)
|
||||
}
|
||||
|
||||
e, code := settleEscrow(t, s, "tok",
|
||||
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
|
||||
if code != 200 || e.State != storage.EscrowRejected {
|
||||
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
|
||||
}
|
||||
if e.Reason != "insufficient_funds" {
|
||||
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
|
||||
}
|
||||
|
||||
st, _ := storage.Chips(user)
|
||||
if st.Chips != 0 || st.Pending != 0 {
|
||||
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
|
||||
// destroyed the moment the cash-out opened, so if we simply mark it done the
|
||||
// player's money is gone from both sides. It has to come back.
|
||||
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
const user = "@unlucky:parodia.dev"
|
||||
|
||||
buy, err := storage.RequestBuyIn(user, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
|
||||
t.Fatalf("fund = %d", code)
|
||||
}
|
||||
|
||||
out, err := storage.RequestCashOut(user, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, _ := storage.Chips(user); st.Chips != 0 {
|
||||
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
|
||||
}
|
||||
|
||||
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
|
||||
if code != 200 {
|
||||
t.Fatalf("settle = %d", code)
|
||||
}
|
||||
if e.State != storage.EscrowFunded {
|
||||
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
|
||||
}
|
||||
if st, _ := storage.Chips(user); st.Chips != 300 {
|
||||
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
|
||||
// gogobee has already moved real euros for it, and no amount of retrying will
|
||||
// conjure the row, so the answer is the one that parks the row in gogobee's
|
||||
// queue for a human rather than the one that retries forever.
|
||||
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
|
||||
t.Fatalf("settle unknown guid = %d, want 400", code)
|
||||
}
|
||||
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
|
||||
t.Fatalf("claim unknown guid = %d, want 404", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
|
||||
// the same mux as the public news site.
|
||||
func TestEscrowNeedsTheBearerToken(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
|
||||
t.Fatalf("poll with a bad token = %d, want 401", code)
|
||||
}
|
||||
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
|
||||
t.Fatalf("claim with no token = %d, want 401", code)
|
||||
}
|
||||
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
|
||||
t.Fatalf("settle with a bad token = %d, want 401", code)
|
||||
}
|
||||
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
|
||||
t.Fatal("an unauthenticated settle created chips")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
|
||||
// `null` body decodes fine in Go but is a trap for anything else that ever reads
|
||||
// this, and an empty poll is the overwhelmingly common case.
|
||||
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEscrowPending(w, req)
|
||||
if got := w.Body.String(); got != "[]\n" {
|
||||
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
|
||||
}
|
||||
}
|
||||
224
internal/web/games_trivia.go
Normal file
224
internal/web/games_trivia.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Trivia, played for chips.
|
||||
//
|
||||
// The same shape as the other tables: the browser sends intents, the server
|
||||
// holds the state, and the payload carries only what the player is entitled to
|
||||
// see. Here that means the four answers *without* which of them is right. The
|
||||
// right one is an index in the engine state, which is in game_live_hands, on
|
||||
// this side of the wire — and it only ever crosses in the event that reveals it,
|
||||
// once the question has been decided and it can't be used to answer.
|
||||
//
|
||||
// The clock is the other half. The countdown in the browser is decoration: the
|
||||
// only clock that scores anything is time.Now() here, measured against the
|
||||
// AskedAt the server stamped when it served the question. A player who stops
|
||||
// their own countdown, or reloads to restart it, changes nothing.
|
||||
|
||||
// triviaView is a game as its player may see it.
|
||||
type triviaView struct {
|
||||
Tier trivia.Tier `json:"tier"`
|
||||
Rung int `json:"rung"` // how many they've answered
|
||||
Rungs int `json:"rungs"` // how many there are
|
||||
|
||||
Category string `json:"category,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"` // and *not* which one is right
|
||||
|
||||
Limit int `json:"limit"` // the tier's seconds per question
|
||||
Left float64 `json:"left"` // seconds this question has left, by the server's clock
|
||||
|
||||
Multiple float64 `json:"multiple"`
|
||||
Bet int64 `json:"bet"`
|
||||
Stands int64 `json:"stands"` // what taking the money right now actually pays
|
||||
CanWalk bool `json:"can_walk"` // false on the first question: see the engine
|
||||
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewTrivia(g trivia.State, now time.Time) triviaView {
|
||||
v := triviaView{
|
||||
Tier: g.Tier,
|
||||
Rung: g.Rung,
|
||||
Rungs: trivia.Rungs,
|
||||
Limit: g.Tier.Limit,
|
||||
// What the player would actually collect, rake already out of it — quoting
|
||||
// the pre-rake figure would have the felt advertising a payout the house
|
||||
// doesn't hand over.
|
||||
Stands: g.Pays(),
|
||||
Multiple: g.Multiple,
|
||||
Bet: g.Bet,
|
||||
CanWalk: g.Rung > 0,
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
// A finished game has no live question, and must not ship the next one — the
|
||||
// ladder still has rungs on it that a later game might deal.
|
||||
if g.Phase == trivia.PhasePlaying {
|
||||
q := g.Live()
|
||||
v.Category = q.Category
|
||||
v.Question = q.Text
|
||||
v.Answers = q.Answers
|
||||
v.Left = g.Left(now).Seconds()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// handleTriviaStart takes the bet and builds a ladder. Same order as every other
|
||||
// table: the chips are staked first, in the same statement that checks they
|
||||
// exist, so two starts fired at once cannot bet the same chip.
|
||||
func (s *Server) handleTriviaStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := trivia.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a difficulty"})
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
rng := rand.New(rand.NewPCG(seed1, seed2))
|
||||
|
||||
// Draw the ladder *before* taking the money. A bank too thin to deal from is
|
||||
// the one failure here that isn't the player's fault, and they should not have
|
||||
// to be refunded for it.
|
||||
qs, err := storage.DrawTrivia(tier.Difficulty, trivia.Rungs, rng)
|
||||
if errors.Is(err, storage.ErrBankEmpty) {
|
||||
writeJSONStatus(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "the question bank is still filling up — give it a minute",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: trivia draw", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: trivia stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
g, evs, err := trivia.New(req.Bet, tier, blackjack.DefaultRules().RakePct, qs, now, rng)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: trivia start", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistTrivia(w, user, g, evs, seed1, seed2, true, now)
|
||||
}
|
||||
|
||||
// handleTriviaAnswer plays one move: pick an answer, or take the money.
|
||||
func (s *Server) handleTriviaAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move trivia.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": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: trivia load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameTrivia {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
}
|
||||
var g trivia.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable trivia game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// The server's clock, and the only one that counts. Read once, so the answer
|
||||
// and the view that reports it agree about what time it is.
|
||||
now := time.Now()
|
||||
|
||||
next, evs, err := trivia.ApplyMove(g, move, now)
|
||||
if err != nil {
|
||||
msg := "that move isn't legal here"
|
||||
if errors.Is(err, trivia.ErrNothingBanked) {
|
||||
msg = "answer one before you walk"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistTrivia(w, user, next, evs, live.Seed1, live.Seed2, false, now)
|
||||
}
|
||||
|
||||
// persistTrivia writes the game back and answers the browser.
|
||||
func (s *Server) persistTrivia(w http.ResponseWriter, user string, g trivia.State, evs []trivia.Event, seed1, seed2 uint64, fresh bool, now time.Time) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal trivia", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == trivia.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameTrivia, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but the
|
||||
// browser still needs the final board to land the verdict on.
|
||||
if done {
|
||||
tv := viewTrivia(g, now)
|
||||
v.Trivia = &tv
|
||||
}
|
||||
v.TrivEvents = evs
|
||||
writeJSON(w, v)
|
||||
}
|
||||
284
internal/web/games_uno.go
Normal file
284
internal/web/games_uno.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// UNO, played for chips against bots.
|
||||
//
|
||||
// The seam is the same as every other table, but there is one thing here that no
|
||||
// other table has: opponents. The obvious way to give a browser opponents is a
|
||||
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
|
||||
// goes up, and what comes back is the player's move *plus every bot turn it
|
||||
// handed off to*, as a script of events. One request, one round of the table.
|
||||
//
|
||||
// What the browser is allowed to see: its own hand, the card in play, the colour
|
||||
// in play, and how many cards each bot is holding. Not the deck, not a bot's
|
||||
// hand, not even the face of a card a bot drew. That last one is most of the
|
||||
// deck, and it is the thing that would turn a game of counting cards into a game
|
||||
// of reading the network tab.
|
||||
|
||||
// unoCardView is one card, ready to draw. The browser gets the colour and the
|
||||
// face as words, not as the engine's integers — the same bargain the blackjack
|
||||
// table makes, and for the same reason: the browser draws faces, not logic.
|
||||
type unoCardView struct {
|
||||
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
|
||||
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
|
||||
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
|
||||
}
|
||||
|
||||
func viewUnoCard(c uno.Card) unoCardView {
|
||||
return unoCardView{
|
||||
Color: c.Color.String(),
|
||||
Value: c.Value.String(),
|
||||
Wild: c.IsWild(),
|
||||
}
|
||||
}
|
||||
|
||||
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
|
||||
// cards are a *count*. There is no field here for what they are.
|
||||
type unoSeatView struct {
|
||||
Name string `json:"name"`
|
||||
Cards int `json:"cards"`
|
||||
You bool `json:"you"`
|
||||
Uno bool `json:"uno"` // down to one card
|
||||
}
|
||||
|
||||
// unoView is a game as its player may see it.
|
||||
type unoView struct {
|
||||
Tier uno.Tier `json:"tier"`
|
||||
Seats []unoSeatView `json:"seats"`
|
||||
|
||||
Hand []unoCardView `json:"hand"` // yours, and only yours
|
||||
Playable []int `json:"playable"` // which of them can legally go down
|
||||
Top unoCardView `json:"top"` // the card in play
|
||||
Color string `json:"color"` // the colour in play, which a wild renames
|
||||
Deck int `json:"deck"` // cards left to draw
|
||||
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Winner int `json:"winner"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewUno(g uno.State) unoView {
|
||||
v := unoView{
|
||||
Tier: g.Tier,
|
||||
Top: viewUnoCard(g.Top()),
|
||||
Color: g.Color.String(),
|
||||
Deck: g.Left(),
|
||||
Turn: g.Turn,
|
||||
Dir: g.Dir,
|
||||
Bet: g.Bet,
|
||||
Pays: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Winner: -1,
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
for i, n := range g.Counts() {
|
||||
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
|
||||
if i == uno.You {
|
||||
seat.Name = "You"
|
||||
} else if i-1 < len(g.Bots) {
|
||||
seat.Name = g.Bots[i-1]
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
if n == 0 {
|
||||
v.Winner = i
|
||||
}
|
||||
}
|
||||
for _, c := range g.Hands[uno.You] {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
v.Playable = g.Playable()
|
||||
if v.Playable == nil {
|
||||
v.Playable = []int{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// unoEventView is one beat of the script the table plays back: a card going
|
||||
// down, a seat eating a +4, the turn coming round. The engine's own events carry
|
||||
// engine types, so they are re-rendered here rather than shipped raw — and this
|
||||
// is also the wall where a bot's drawn card is dropped on the floor.
|
||||
type unoEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Card *unoCardView `json:"card,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func viewUnoEvents(evs []uno.Event) []unoEventView {
|
||||
out := make([]unoEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
|
||||
if e.Color != uno.Wild {
|
||||
v.Color = e.Color.String()
|
||||
}
|
||||
if e.Card != nil {
|
||||
// The engine only ever attaches a card to an event the seat is entitled
|
||||
// to see it in — a card played face up, or one *you* drew. This check is
|
||||
// the belt to that pair of braces: a bot's draw never carries a face,
|
||||
// whatever the engine thinks it's doing.
|
||||
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
|
||||
if e.Seat == uno.You {
|
||||
c := viewUnoCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
} else {
|
||||
c := viewUnoCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleUnoStart takes the bet and deals. Same order as every other table: the
|
||||
// chips are staked first, in the same statement that checks they exist, so two
|
||||
// deals fired at once cannot bet the same chip.
|
||||
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := uno.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: uno stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: uno deal", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
|
||||
// The bots' turns come back with it.
|
||||
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move uno.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": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: uno load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameUno {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable uno game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := uno.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// The refusals a player can actually cause, said in words rather than as
|
||||
// "that move isn't legal here" — which, in a game with this many rules, is
|
||||
// the table refusing to explain itself.
|
||||
msg := "that move isn't legal here"
|
||||
switch {
|
||||
case errors.Is(err, uno.ErrCantPlay):
|
||||
msg = "that card doesn't go on this one"
|
||||
case errors.Is(err, uno.ErrNeedColor):
|
||||
msg = "pick a colour for the wild"
|
||||
case errors.Is(err, uno.ErrMustPlayNow):
|
||||
msg = "play the card you drew, or pass"
|
||||
case errors.Is(err, uno.ErrCantPass):
|
||||
msg = "draw first, then you can pass"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistUno writes the game back and answers the browser.
|
||||
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal uno", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == uno.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameUno, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but the
|
||||
// browser still needs the final board to land the verdict on.
|
||||
if done {
|
||||
uv := viewUno(g)
|
||||
v.Uno = &uv
|
||||
}
|
||||
v.UnoEvents = viewUnoEvents(evs)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
147
internal/web/games_uno_test.go
Normal file
147
internal/web/games_uno_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and no
|
||||
// card a player is not entitled to see leaves the server. At UNO that is not one
|
||||
// hole card — it is the deck and every bot's hand, which is most of the game.
|
||||
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "full"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Game != gameUno || v.Uno == nil {
|
||||
t.Fatalf("start returned no uno game: game=%q", v.Game)
|
||||
}
|
||||
|
||||
g := v.Uno
|
||||
if len(g.Hand) != uno.HandSize {
|
||||
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
|
||||
}
|
||||
if len(g.Seats) != 4 {
|
||||
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
|
||||
}
|
||||
// A bot is a name and a number. There is no field here that could carry a
|
||||
// card, which is the point — this is a compile-time guarantee, and the test
|
||||
// exists to make deleting it loud.
|
||||
for i, seat := range g.Seats {
|
||||
if i == 0 {
|
||||
if !seat.You || seat.Name != "You" {
|
||||
t.Errorf("seat 0 should be you: %+v", seat)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seat.You || seat.Name == "" {
|
||||
t.Errorf("seat %d should be a named bot: %+v", i, seat)
|
||||
}
|
||||
if seat.Cards != uno.HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
|
||||
}
|
||||
}
|
||||
if g.Top.Value == "" {
|
||||
t.Error("no card in play")
|
||||
}
|
||||
if g.Turn != 0 {
|
||||
t.Errorf("you play first, turn is %d", g.Turn)
|
||||
}
|
||||
}
|
||||
|
||||
// A move plays your turn and every bot turn behind it, and the script that comes
|
||||
// back never carries a bot's drawn card.
|
||||
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "table"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
}
|
||||
|
||||
// Draw, which is legal from any hand: the turn passes to the bots and comes
|
||||
// back, unless the card drawn happens to be playable.
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "draw"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("draw = %d, want 200", code)
|
||||
}
|
||||
if v.Uno == nil {
|
||||
t.Fatal("the move returned no game")
|
||||
}
|
||||
if len(v.UnoEvents) == 0 {
|
||||
t.Fatal("a move that happened sent back no events")
|
||||
}
|
||||
if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 {
|
||||
t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn)
|
||||
}
|
||||
for _, e := range v.UnoEvents {
|
||||
if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil {
|
||||
t.Fatalf("a bot's drawn card crossed the wire: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// You cannot play a wild without naming a colour — and the zero value of the
|
||||
// colour field is not red, so a move that simply omits it is refused rather than
|
||||
// quietly played as one.
|
||||
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
|
||||
// doesn't take long, and this is the only way to get one without rigging the
|
||||
// deck through a door the server doesn't have.
|
||||
var wild = -1
|
||||
for try := 0; try < 40 && wild < 0; try++ {
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 10, "tier": "duel"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
}
|
||||
for i, c := range v.Uno.Hand {
|
||||
if c.Wild {
|
||||
wild = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if wild < 0 {
|
||||
// Abandon this deal and take another. The live row is keyed on the
|
||||
// player, so it has to go before the next start can be seated.
|
||||
if err := storage.ClearLiveHand(testPlayer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if wild < 0 {
|
||||
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
|
||||
}
|
||||
|
||||
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild}))
|
||||
if code != 400 {
|
||||
t.Fatalf("a wild with no colour = %d, want 400", code)
|
||||
}
|
||||
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
|
||||
if code != 200 {
|
||||
t.Fatalf("a wild played as green = %d, want 200", code)
|
||||
}
|
||||
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
|
||||
// The bots have played since, so the colour may have moved on — what must
|
||||
// not happen is the wild going down as anything we didn't ask for.
|
||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,8 @@ type pageData struct {
|
||||
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
|
||||
PushPublicKey string // VAPID public key handed to the client to subscribe
|
||||
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
|
||||
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
|
||||
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
@@ -117,6 +119,13 @@ type channelPage struct {
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Total int
|
||||
|
||||
// Roster is the live adventurer board, populated on the adventure section
|
||||
// only and only on page 1 — it is present tense, and page 2 of an archive is
|
||||
// not where anyone looks for what's happening right now.
|
||||
Roster []RosterView
|
||||
RosterStale bool
|
||||
ShowRoster bool
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
@@ -158,7 +167,7 @@ func (s *Server) base(r *http.Request) pageData {
|
||||
}
|
||||
d := pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
Channels: s.channels,
|
||||
Weather: currentWeather(time.Now()),
|
||||
AllSources: jsForScript(srcJSON),
|
||||
AuthEnabled: s.auth != nil,
|
||||
@@ -215,8 +224,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dayStart := time.Now().Add(-24 * time.Hour).Unix()
|
||||
stats := make([]channelStat, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
stats := make([]channelStat, 0, len(s.channels))
|
||||
for _, ch := range s.channels {
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
lastTs := storage.GetLastPostTime(ch.Slug)
|
||||
stat := channelStat{
|
||||
@@ -327,6 +336,9 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = ch.Slug
|
||||
// The adventure section names player characters; keep it out of search
|
||||
// indexes to bound the cached-forever exposure (see plan gap #5).
|
||||
base.NoIndex = ch.Slug == "adventure"
|
||||
data := channelPage{
|
||||
pageData: base,
|
||||
Channel: ch,
|
||||
@@ -338,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
||||
Total: total,
|
||||
}
|
||||
if ch.Slug == "adventure" && page == 1 {
|
||||
data.Roster, data.RosterStale, _ = s.roster()
|
||||
data.ShowRoster = true
|
||||
}
|
||||
s.render(w, "channel", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
183
internal/web/roster.go
Normal file
183
internal/web/roster.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The live adventurer board.
|
||||
//
|
||||
// Everything else Pete publishes about the realm is an *accomplishment* — a
|
||||
// death, a clear, a milestone. Those are clippings: they read as archive the
|
||||
// instant they land, however fast we deliver them, and no refresh interval fixes
|
||||
// that. The board is the opposite kind of thing. It is state that is currently
|
||||
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
|
||||
// hearing from gogobee.
|
||||
//
|
||||
// Direction of travel is gogobee → Pete, like every other adventure payload:
|
||||
// Pete has no route back into the game box's network and we are not opening one.
|
||||
// gogobee pushes the whole board; we replace ours with it.
|
||||
|
||||
const (
|
||||
// rosterStaleAfter — how old a snapshot can get before the board stops
|
||||
// claiming to be live. gogobee pushes every couple of minutes, so this is
|
||||
// several missed pushes, not one unlucky one.
|
||||
//
|
||||
// This matters more than it looks: if gogobee dies mid-expedition, the last
|
||||
// snapshot says "Josie is in holymachina" and would say so forever. A board
|
||||
// that lies confidently is worse than one that admits it lost the wire —
|
||||
// especially once players can act on it (see the target-list note below).
|
||||
rosterStaleAfter = 12 * time.Minute
|
||||
|
||||
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
|
||||
// exists so a malformed or hostile push can't spool unbounded rows.
|
||||
rosterMaxEntries = 500
|
||||
)
|
||||
|
||||
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
|
||||
type rosterPush struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Adventurers []storage.RosterEntry `json:"adventurers"`
|
||||
}
|
||||
|
||||
// RosterView is one row as the page renders it.
|
||||
type RosterView struct {
|
||||
Name string
|
||||
Level int
|
||||
ClassRace string
|
||||
OnRun bool
|
||||
Zone string
|
||||
Region string
|
||||
Where string // "holymachina, day 3" | "in town"
|
||||
Idle string // "quiet for 2 days" — only when idle
|
||||
}
|
||||
|
||||
// handleRosterIngest replaces the board with gogobee's latest snapshot.
|
||||
func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var push rosterPush
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.Adventurers) > rosterMaxEntries {
|
||||
http.Error(w, "roster too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A snapshot with no timestamp can't be aged, so it could never go stale —
|
||||
// it would sit on the page claiming to be live forever. Treat it as now.
|
||||
if push.SnapshotAt <= 0 {
|
||||
push.SnapshotAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Never trust the channel with a name. Same rule as factGuard: gogobee
|
||||
// pre-sanitizes to character names, and Pete checks anyway, because this is
|
||||
// the last thing standing between the payload and a public page.
|
||||
for i, e := range push.Adventurers {
|
||||
if e.Token == "" || e.Name == "" {
|
||||
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if e.Status != "expedition" && e.Status != "idle" {
|
||||
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
|
||||
slog.Error("roster ingest: replace failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
|
||||
// open tab goes live without a reload. Public — same exposure as the rendered
|
||||
// page, no more.
|
||||
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
views, stale, _ := s.roster()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"stale": stale,
|
||||
"adventurers": views,
|
||||
})
|
||||
}
|
||||
|
||||
// roster loads the board and decides whether it is still live. A stale board is
|
||||
// still returned — the page shows it dimmed and says so, rather than blanking,
|
||||
// because "here is who was out when we lost contact" beats an empty page.
|
||||
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
|
||||
entries, snapshotAt, err := storage.LoadRoster()
|
||||
if err != nil {
|
||||
slog.Error("roster: load failed", "err", err)
|
||||
return nil, true, 0
|
||||
}
|
||||
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
|
||||
for _, e := range entries {
|
||||
views = append(views, toRosterView(e))
|
||||
}
|
||||
return views, stale, snapshotAt
|
||||
}
|
||||
|
||||
func toRosterView(e storage.RosterEntry) RosterView {
|
||||
v := RosterView{
|
||||
Name: e.Name,
|
||||
Level: e.Level,
|
||||
ClassRace: e.ClassRace,
|
||||
OnRun: e.Status == "expedition",
|
||||
Zone: e.Zone,
|
||||
Region: e.Region,
|
||||
}
|
||||
if v.OnRun {
|
||||
where := e.Zone
|
||||
if e.Region != "" {
|
||||
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
|
||||
}
|
||||
if e.Day > 0 {
|
||||
where = fmt.Sprintf("%s — day %d", where, e.Day)
|
||||
}
|
||||
v.Where = where
|
||||
return v
|
||||
}
|
||||
v.Where = "in town"
|
||||
v.Idle = humanIdle(e.IdleHours)
|
||||
return v
|
||||
}
|
||||
|
||||
// humanIdle renders the idle clock the way a person would say it. Deliberately
|
||||
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
|
||||
func humanIdle(hours int) string {
|
||||
switch {
|
||||
case hours <= 0:
|
||||
return ""
|
||||
case hours < 2:
|
||||
return "just got back"
|
||||
case hours < 24:
|
||||
return fmt.Sprintf("quiet for %dh", hours)
|
||||
case hours < 48:
|
||||
return "quiet for a day"
|
||||
default:
|
||||
return fmt.Sprintf("quiet for %d days", hours/24)
|
||||
}
|
||||
}
|
||||
193
internal/web/roster_test.go
Normal file
193
internal/web/roster_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(push)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleRosterIngest(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func entry(token, name, status, zone string) storage.RosterEntry {
|
||||
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
|
||||
}
|
||||
|
||||
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
|
||||
// sends the *complete* set of adventurers it is willing to show; anyone missing
|
||||
// from a later snapshot has left the board — they deleted their character, or
|
||||
// (the case that actually matters) they just ran `!news optout` and must
|
||||
// disappear from a public page. An upsert would leave them standing there
|
||||
// forever, which is precisely the exposure the opt-out exists to prevent.
|
||||
func TestRosterReplacesNeverMerges(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
entry("t2", "Quack", "idle", ""),
|
||||
}}); w.Code != 200 {
|
||||
t.Fatalf("first push = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Quack opts out; gogobee stops including her.
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
}}); w.Code != 200 {
|
||||
t.Fatalf("second push = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
views, stale, _ := s.roster()
|
||||
if stale {
|
||||
t.Error("fresh snapshot read as stale")
|
||||
}
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
|
||||
}
|
||||
if views[0].Name != "Josie" {
|
||||
t.Errorf("board kept %q, want Josie", views[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
|
||||
// "Josie is in holymachina" and would say so forever. The board has to admit it
|
||||
// lost the wire rather than keep asserting a stale fact as live.
|
||||
func TestRosterGoesStale(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
|
||||
|
||||
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
|
||||
entry("t1", "Josie", "expedition", "holymachina"),
|
||||
}})
|
||||
|
||||
views, stale, _ := s.roster()
|
||||
if !stale {
|
||||
t.Error("snapshot older than rosterStaleAfter still reported live")
|
||||
}
|
||||
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
|
||||
if len(views) != 1 {
|
||||
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
|
||||
// legitimate state and must not be confused with gogobee having gone away. This
|
||||
// is why the snapshot time lives in its own row instead of MAX() over entries.
|
||||
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
|
||||
t.Fatalf("empty push = %d, want 200", w.Code)
|
||||
}
|
||||
views, stale, _ := s.roster()
|
||||
if len(views) != 0 {
|
||||
t.Errorf("empty snapshot produced %d rows", len(views))
|
||||
}
|
||||
if stale {
|
||||
t.Error("a quiet realm read as a dead wire — the two must not collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterIngestRejects(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
now := time.Now().Unix()
|
||||
|
||||
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
|
||||
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||
}
|
||||
// An unknown status would render as neither "out there" nor "in town".
|
||||
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Name: "Josie", Status: "vibing"},
|
||||
}}
|
||||
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
|
||||
t.Errorf("unknown status = %d, want 400", w.Code)
|
||||
}
|
||||
// A nameless row would render an empty slot on a public page.
|
||||
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Status: "idle"},
|
||||
}}
|
||||
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
|
||||
t.Errorf("nameless adventurer = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
|
||||
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
|
||||
// 400 that retries and parks the bulletin forever.
|
||||
func TestDepartureRenders(t *testing.T) {
|
||||
headline, lede, ok := renderAdventure(AdvFact{
|
||||
EventType: "departure",
|
||||
Subject: "Camcast",
|
||||
Zone: "crypt_valdris",
|
||||
Level: 1,
|
||||
Actors: []string{"Camcast"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
|
||||
}
|
||||
if headline == "" || lede == "" {
|
||||
t.Error("departure rendered empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterViewWhere(t *testing.T) {
|
||||
out := toRosterView(storage.RosterEntry{
|
||||
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
|
||||
})
|
||||
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
|
||||
t.Errorf("expedition row = %+v", out)
|
||||
}
|
||||
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
|
||||
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
|
||||
t.Errorf("idle row = %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdventurePageRendersBoard drives the real page through the real template.
|
||||
// The handler tests above prove the data is right; this proves it reaches a
|
||||
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
|
||||
// 500s the whole adventure section at request time, board and clippings alike.
|
||||
func TestAdventurePageRendersBoard(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
|
||||
postRoster(t, s, "tok", rosterPush{
|
||||
SnapshotAt: time.Now().Unix(),
|
||||
Adventurers: []storage.RosterEntry{
|
||||
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
|
||||
Status: "expedition", Zone: "holymachina", Day: 3},
|
||||
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
|
||||
Status: "idle", IdleHours: 50},
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/adventure", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET /adventure = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Out there right now", // the board's headline
|
||||
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
|
||||
"Quack", "in town", "quiet for 2 days",
|
||||
"/api/roster", // the client re-poll, so an open tab stays true
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("adventure page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,10 @@ type Channel struct {
|
||||
}
|
||||
|
||||
// channels in display order. Add a new entry here to add a section.
|
||||
//
|
||||
// This is the full catalogue, used to resolve a story's channel slug to its
|
||||
// display metadata. It is NOT the list of live sections: an entry can be gated
|
||||
// off (adventure), so route registration and the nav read s.channels instead.
|
||||
var channels = []Channel{
|
||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
||||
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
||||
@@ -42,6 +47,7 @@ var channels = []Channel{
|
||||
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
|
||||
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
|
||||
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
|
||||
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
|
||||
}
|
||||
|
||||
// SourceInfo is the trimmed view of a configured feed for the settings panel.
|
||||
@@ -61,6 +67,9 @@ type Server struct {
|
||||
tts *ttsService // nil when server-side read-aloud is disabled
|
||||
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
||||
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
||||
adv config.AdventureConfig // gogobee adventure-news seam
|
||||
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
|
||||
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
|
||||
|
||||
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
||||
// Guarded by metricsMu; never persisted (see metrics.go).
|
||||
@@ -69,23 +78,40 @@ type Server struct {
|
||||
salt [16]byte
|
||||
}
|
||||
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
"templates/layout.html",
|
||||
"templates/_card.html",
|
||||
"templates/"+p+".html",
|
||||
)
|
||||
// New builds the server. Templates are parsed once at startup. Each page gets
|
||||
// its own template set sharing a layout, which avoids `main` define collisions
|
||||
// between pages.
|
||||
//
|
||||
// There are two layouts, and they are not the same site. The news pages hang off
|
||||
// layout.html: Pete's face, the channel nav, search, the reader, the weather
|
||||
// canvas. The casino hangs off games_layout.html, which shares the *design* —
|
||||
// the palette vars, the fonts, the fat rounded cards — and none of the
|
||||
// furniture. games.parodia.dev is its own place, not a news page with a felt on
|
||||
// it, so it doesn't inherit a single control it has no use for.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
||||
sets := []struct {
|
||||
layout string
|
||||
shared []string
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
for _, set := range sets {
|
||||
for _, p := range set.pages {
|
||||
files := []string{"templates/" + set.layout + ".html"}
|
||||
for _, s := range set.shared {
|
||||
files = append(files, "templates/"+s+".html")
|
||||
}
|
||||
files = append(files, "templates/"+p+".html")
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tpls[p] = t
|
||||
}
|
||||
}
|
||||
infos := make([]SourceInfo, 0, len(sources))
|
||||
for _, sc := range sources {
|
||||
if !sc.Enabled || sc.Name == "" {
|
||||
@@ -99,7 +125,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
adminSubs[sub] = true
|
||||
}
|
||||
}
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
|
||||
// The adventure section only exists when it's enabled: with the seam off,
|
||||
// there's no nav tab, no /adventure page and no /adventure feed, rather than
|
||||
// an empty section nobody can fill.
|
||||
live := make([]Channel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
if ch.Slug == "adventure" && !adv.Enabled {
|
||||
continue
|
||||
}
|
||||
live = append(live, ch)
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
|
||||
|
||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||
// provider is unreachable at boot we log and serve anonymously rather than
|
||||
@@ -147,7 +184,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/related", s.handleRelated)
|
||||
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
||||
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
||||
for _, ch := range channels {
|
||||
for _, ch := range s.channels {
|
||||
ch := ch
|
||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChannel(w, r, ch)
|
||||
@@ -165,6 +202,39 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
|
||||
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
|
||||
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
|
||||
|
||||
// The live board. Ingest is bearer-authed like the fact seam; the read side
|
||||
// is public because it renders on a public page anyway.
|
||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
|
||||
|
||||
// Themed per-event emblem (card + og:image). A distinct path depth from
|
||||
// /adventure/{guid}, so the two patterns never overlap.
|
||||
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
|
||||
|
||||
// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
|
||||
// and pushes the verdict back. Bearer-authed on the same ingest token as the
|
||||
// adventure seam, so like it, these sit outside the sign-in block — the caller
|
||||
// is a machine on the tailnet, not a browser.
|
||||
mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
|
||||
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||
|
||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||
if s.gamesReady() {
|
||||
s.casinoRoutes(mux)
|
||||
}
|
||||
|
||||
if s.auth != nil {
|
||||
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||
@@ -187,12 +257,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
Handler: s.hostRouter(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// hostRouter puts the casino at the root of its own hostname without giving it
|
||||
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
|
||||
// the same port — Caddy sends both here — so a request that arrives on the games
|
||||
// host has /games prefixed onto its path, and "/" lands on the lobby.
|
||||
//
|
||||
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
|
||||
// the same plumbing whichever door you came in by, and the login round-trip in
|
||||
// particular has to keep working on both hosts.
|
||||
func (s *Server) hostRouter(mux http.Handler) http.Handler {
|
||||
host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
|
||||
if host == "" || !s.gamesReady() {
|
||||
return mux
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
|
||||
mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Rewrite a copy: the request's URL is shared with anything that logged it.
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
|
||||
mux.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
|
||||
// isSharedPath marks the paths that mean the same thing on every host and must
|
||||
// not be shuffled under /games.
|
||||
func isSharedPath(p string) bool {
|
||||
for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
|
||||
}
|
||||
|
||||
// hostOnly strips the port from a Host header.
|
||||
func hostOnly(host string) string {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
return host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Start runs the HTTP server and blocks until ctx is canceled.
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
go func() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
414
internal/web/static/js/blackjack.js
Normal file
414
internal/web/static/js/blackjack.js
Normal file
@@ -0,0 +1,414 @@
|
||||
// The blackjack table.
|
||||
//
|
||||
// The browser holds no game. It sends intents — deal, hit, stand, double — and
|
||||
// the server answers with the cards you're allowed to see plus the *script* of
|
||||
// how they got there: one event per card off the shoe, in the order the shoe
|
||||
// gave them up. This file's job is to play that script back at a human speed
|
||||
// rather than snapping the finished hand into place.
|
||||
//
|
||||
// Which is also why the hole card works the way it does: the server sends a
|
||||
// "dealer_hole" event with no card attached, because while you are still acting
|
||||
// it hasn't told anyone what that card is. It arrives with the reveal.
|
||||
//
|
||||
// The money is animated on the same principle. Every number the server sends is
|
||||
// also a movement: a stake is chips leaving your pile and landing on the spot, a
|
||||
// payout is chips coming out of the house's rack, a loss is your stack being
|
||||
// taken away. Nothing about the money changes on this table without something
|
||||
// crossing the felt to make it change — including the count in the chip bar,
|
||||
// which is deliberately not updated until the chips that justify it have landed.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-blackjack]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var dealerEl = root.querySelector("[data-dealer]");
|
||||
var playerEl = root.querySelector("[data-player]");
|
||||
var dTotalEl = root.querySelector("[data-dealer-total]");
|
||||
var pTotalEl = root.querySelector("[data-player-total]");
|
||||
var dLabelEl = root.querySelector("[data-dealer-label]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var actions = root.querySelector("[data-actions]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var dealBtn = root.querySelector("[data-deal]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
|
||||
// The three places a chip can be: your pile (in the bar above), the spot in
|
||||
// front of you, and the house's rack on the felt.
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var stackEl = root.querySelector("[data-stack]");
|
||||
var spotTotalEl = root.querySelector("[data-spot-total]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// The spot owns the chips on the felt and the number under them — see PeteFX.
|
||||
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
|
||||
// at a default stake nobody put down.
|
||||
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
|
||||
|
||||
var bet = 0; // what you're building between hands
|
||||
var busy = false; // a request is in flight, or cards are still landing
|
||||
var hand = null; // the hand as the server last described it
|
||||
|
||||
var DEAL_MS = 380; // one card's flight, and the gap before the next
|
||||
var FLIP_MS = 450;
|
||||
var BEAT_MS = 600; // the dealer thinking before they draw out
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone) {
|
||||
if (!text) { msgEl.classList.add("hidden"); return; }
|
||||
msgEl.textContent = text;
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
//
|
||||
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
|
||||
// every other table in the room. Here a card is always dealt out of the shoe
|
||||
// and always lands with a degree or two of tilt on it, which are this table's
|
||||
// two opinions about a card and the only ones it has.
|
||||
|
||||
var CARDS = window.PeteCards;
|
||||
|
||||
function cardEl(face) { return CARDS.el(face); }
|
||||
var turnOver = CARDS.turnOver;
|
||||
|
||||
// ---- the money on the felt -------------------------------------------------
|
||||
|
||||
// stake moves chips from your pile onto the spot: the bet you build before a
|
||||
// deal, and the second bet a double puts down beside it.
|
||||
function stake(amount, from) {
|
||||
return spot.pour(from || purseEl, amount);
|
||||
}
|
||||
|
||||
// settleChips is what the felt does about the outcome, after the cards have
|
||||
// finished telling you what it is. It reads the same two numbers the ledger
|
||||
// moved: `bet` (already off your pile since the deal) and `payout` (what comes
|
||||
// back — stake plus winnings less rake, or nothing at all).
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
var back = payout - final.bet; // what the house is adding, if anything
|
||||
|
||||
if (payout <= 0) {
|
||||
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
}
|
||||
|
||||
// The house pays first, into the spot beside your stake, so you watch the
|
||||
// winnings arrive on top of the bet that earned them.
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||
// does the number in the bar move.
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
function totals(v) {
|
||||
if (v.total) {
|
||||
pTotalEl.textContent = v.total + (v.soft ? " (soft)" : "");
|
||||
pTotalEl.classList.remove("hidden");
|
||||
} else {
|
||||
pTotalEl.classList.add("hidden");
|
||||
}
|
||||
// While the hole card is down, the dealer's total is only what's showing —
|
||||
// so say so, rather than printing a number that quietly means something else.
|
||||
if (v.dealer && v.dealer.length) {
|
||||
dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
|
||||
dTotalEl.classList.remove("hidden");
|
||||
} else {
|
||||
dTotalEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// paint puts a hand on the felt with no animation. This is the resume path:
|
||||
// you reloaded, or Pete restarted, and your cards are simply there — including
|
||||
// the stake, which is still on the spot because the server still has it.
|
||||
function paint(v) {
|
||||
dealerEl.innerHTML = "";
|
||||
playerEl.innerHTML = "";
|
||||
if (!v) { setPhase(null); spot.render(0); return; }
|
||||
|
||||
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
|
||||
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
|
||||
if (v.hole) dealerEl.appendChild(cardEl(null));
|
||||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||
totals(v);
|
||||
setPhase(v);
|
||||
}
|
||||
|
||||
var VERDICTS = {
|
||||
blackjack: "Blackjack! 🎉",
|
||||
win: "You win!",
|
||||
dealer_bust: "Dealer busts. You win!",
|
||||
lose: "Dealer takes it.",
|
||||
bust: "Bust.",
|
||||
push: "Push — your bet comes back.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
playerEl.dataset.won = v.net > 0 ? "1" : v.net < 0 ? "-1" : "0";
|
||||
|
||||
// The one thing in this room that gets confetti. A natural is rare, it pays
|
||||
// 3:2, and if everything celebrated then nothing would.
|
||||
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
|
||||
}
|
||||
|
||||
// setPhase swaps the controls: bet between hands, act during one.
|
||||
function setPhase(v) {
|
||||
hand = v;
|
||||
var live = !!v && v.phase === "player";
|
||||
betting.classList.toggle("hidden", live);
|
||||
actions.classList.toggle("hidden", !live);
|
||||
|
||||
if (live) {
|
||||
var dbl = actions.querySelector('[data-move="double"]');
|
||||
if (dbl) dbl.disabled = !v.can_double;
|
||||
}
|
||||
if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome));
|
||||
}
|
||||
|
||||
// ---- the script -----------------------------------------------------------
|
||||
|
||||
// play walks the server's events, one card at a time. It is deliberately the
|
||||
// only thing that renders during a hand: the final state is applied at the end,
|
||||
// so what you watch and what the server says can't disagree halfway through.
|
||||
//
|
||||
// `money` is the one exception, and it's a deliberate one. On a hand that is
|
||||
// still running, the chip bar is right immediately — your stake left your pile
|
||||
// when you pressed Deal, and it's sitting on the spot where you can see it. On
|
||||
// a hand that *settles*, the bar is left alone until the chips have physically
|
||||
// come home, because a counter that pays you before the dealer has turned over
|
||||
// is a counter that has told you the ending.
|
||||
function play(view, money) {
|
||||
var events = view.events || [];
|
||||
var final = view.hand;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var hole = null; // the face-down card element, once one has been dealt
|
||||
var chain = Promise.resolve();
|
||||
var drew = false; // has the dealer drawn since the reveal?
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
// Whatever the server says the stake is, that's what has to be on the spot.
|
||||
// Two things get here: a double, which puts a second bet down beside the
|
||||
// first, and a deal whose bet was typed rather than stacked (you kept last
|
||||
// hand's number and just pressed Deal). Either way the chips go down before
|
||||
// the card they're buying does.
|
||||
if (final && final.bet > spot.amount) {
|
||||
var extra = final.bet - spot.amount;
|
||||
chain = chain.then(function () { return stake(extra); });
|
||||
}
|
||||
|
||||
events.forEach(function (e) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "deal":
|
||||
dealerEl.innerHTML = "";
|
||||
playerEl.innerHTML = "";
|
||||
playerEl.dataset.won = "0";
|
||||
verdictEl.classList.add("hidden");
|
||||
return;
|
||||
|
||||
case "player_card":
|
||||
playerEl.appendChild(cardEl(e.card));
|
||||
return wait(DEAL_MS);
|
||||
|
||||
case "dealer_card":
|
||||
// The dealer takes a moment before the first card they draw out.
|
||||
// Card, card, card with no breath in between is a machine dealing;
|
||||
// the pause is the only thing on this table that plays as suspense.
|
||||
var beat = drew ? Promise.resolve() : think();
|
||||
drew = true;
|
||||
return beat.then(function () {
|
||||
dealerEl.appendChild(cardEl(e.card));
|
||||
return wait(DEAL_MS);
|
||||
});
|
||||
|
||||
case "dealer_hole":
|
||||
hole = cardEl(null);
|
||||
dealerEl.appendChild(hole);
|
||||
return wait(DEAL_MS);
|
||||
|
||||
case "reveal":
|
||||
// The hole card turns over. Its face is in the final hand — this is
|
||||
// the first moment the server has been willing to say what it was.
|
||||
if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
|
||||
if (hole && final && final.dealer && final.dealer[1]) {
|
||||
turnOver(hole, final.dealer[1]);
|
||||
}
|
||||
return wait(FLIP_MS);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
totals(final);
|
||||
if (!settles) { setPhase(final); return; }
|
||||
|
||||
// The hand is over: nothing is on offer while the money is moving. Hit and
|
||||
// Stand go now, and Deal comes back at the far end.
|
||||
actions.classList.add("hidden");
|
||||
verdict(final);
|
||||
// The chips move, and the bar catches up with them when they arrive. The
|
||||
// betting controls come back last, once the felt is clear: offering Deal
|
||||
// over a table that is still being paid out invites a click the table then
|
||||
// has to refuse.
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// standing leaves your bet up for the next hand, the way you would at a table:
|
||||
// the stake that just settled goes straight back on the spot. It costs nothing
|
||||
// — chips on the spot are a proposal until you press Deal — and it's what keeps
|
||||
// the number in the panel honest, because otherwise a settled hand leaves
|
||||
// "your bet: 300" printed over an empty spot.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
return stake(amount);
|
||||
}
|
||||
|
||||
// think is the dealer's beat: a pause with something to look at, so it reads as
|
||||
// deliberation rather than as the page having hung.
|
||||
function think() {
|
||||
if (reduced || !dLabelEl) return wait(0);
|
||||
dLabelEl.classList.add("pete-dealer-think");
|
||||
return wait(BEAT_MS).then(function () {
|
||||
dLabelEl.classList.remove("pete-dealer-think");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- talking to the table -------------------------------------------------
|
||||
|
||||
function send(path, body) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("");
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
// play() decides *when* the money lands; see the note on it.
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad");
|
||||
// Whatever we thought was on the felt, the server is the authority on it.
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && !v.hand) spot.render(0);
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
// ---- betting --------------------------------------------------------------
|
||||
//
|
||||
// A bet is built by putting chips on the spot, one at a time, and it is those
|
||||
// chips the deal then rides on — the number under the pile is a readout of the
|
||||
// pile, not the other way round.
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
||||
// rack, and the house is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
// The chip you clicked is the chip that flies: same colour, same size, off
|
||||
// the button and onto the felt. The pile only grows once it gets there —
|
||||
// but the spot's total moves now, so a Deal pressed mid-flight still knows
|
||||
// the chip is on its way and doesn't put a second one down.
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (dealBtn) {
|
||||
dealBtn.addEventListener("click", function () {
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
send("/api/games/blackjack/deal", { bet: bet });
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-move]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
send("/api/games/blackjack/move", { move: btn.dataset.move });
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
|
||||
if (!hand || hand.phase !== "player" || busy) return;
|
||||
|
||||
var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()];
|
||||
if (!move) return;
|
||||
if (move === "double" && !hand.can_double) return;
|
||||
e.preventDefault();
|
||||
send("/api/games/blackjack/move", { move: move });
|
||||
});
|
||||
|
||||
// The money bar owns the first fetch; the table picks up whatever it found,
|
||||
// including a hand left sitting on the felt by a reload or a redeploy.
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.hand) paint(v.hand);
|
||||
if (v.hand && v.hand.phase === "done") verdict(v.hand);
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
172
internal/web/static/js/casino-cards.js
Normal file
172
internal/web/static/js/casino-cards.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// The deck, as the room draws it.
|
||||
//
|
||||
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
||||
// span — and at the size a card actually is, a suit character renders as a speck:
|
||||
// the shape is whatever font happened to answer, it doesn't scale, and it can't
|
||||
// be positioned to the half-row a real pip layout needs.
|
||||
//
|
||||
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
||||
// with the suits as vector shapes. Everything below is coordinates on that field,
|
||||
// which is why the pips land where a printed deck puts them instead of where a
|
||||
// flexbox felt like putting them.
|
||||
//
|
||||
// This started life inside blackjack.js. It's out here because solitaire deals
|
||||
// off the same deck, and the second table in a casino is exactly the moment a
|
||||
// copied card renderer becomes two card renderers that drift.
|
||||
//
|
||||
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUIT_ART = {
|
||||
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||||
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||||
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||||
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||||
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||||
};
|
||||
|
||||
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
|
||||
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
||||
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
||||
// them, which is the whole reason this is a table of coordinates and not a
|
||||
// grid. Anything below the middle is printed upside down, so it is.
|
||||
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
||||
var L = 30, C = 50, Rr = 70; // the three columns
|
||||
|
||||
var PIPS = {
|
||||
"A": [[C, 70, 2.1]],
|
||||
"2": [[C, R[1]], [C, R[7]]],
|
||||
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
||||
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
||||
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
||||
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
};
|
||||
|
||||
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
||||
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||||
|
||||
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
||||
// below the middle of the card.
|
||||
function pipAt(suit, x, y, scale) {
|
||||
var s = (scale || 1) * 0.17;
|
||||
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
||||
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||||
SUIT_ART[suit] + "</g>";
|
||||
}
|
||||
|
||||
// The corner index: rank over suit. Printed in both corners, the second one
|
||||
// upside down, which is what lets you read a card from a fanned hand — and in
|
||||
// solitaire, from a column where all you can see is the top eighth of it.
|
||||
function index(face) {
|
||||
var g =
|
||||
'<g>' +
|
||||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||||
"</g>";
|
||||
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
||||
}
|
||||
|
||||
// paint draws the face. Every table uses this one, because they all deal out of
|
||||
// the same deck.
|
||||
function paint(front, face) {
|
||||
front.dataset.red = face.red ? "1" : "0";
|
||||
|
||||
var body = "";
|
||||
if (COURT[face.rank]) {
|
||||
// Court cards: a framed panel, the suit above the letter and again below it
|
||||
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
||||
// stacks two of them into a blob, which is exactly what the first attempt
|
||||
// did. No portrait either — a drawn king would fight the room, and this
|
||||
// reads instantly at the size a card actually is.
|
||||
body =
|
||||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||||
pipAt(face.suit, 50, 38, 0.95) +
|
||||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||||
pipAt(face.suit, 50, 102, 0.95);
|
||||
} else {
|
||||
var spots = PIPS[face.rank] || [];
|
||||
for (var i = 0; i < spots.length; i++) {
|
||||
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
||||
}
|
||||
}
|
||||
|
||||
front.innerHTML =
|
||||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||||
'role="img" aria-label="' + aria(face) + '">' + index(face) + body + "</svg>";
|
||||
}
|
||||
|
||||
// "A♠" is not something a screen reader should be asked to pronounce.
|
||||
function aria(face) {
|
||||
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||||
var suit = SUIT_NAMES[face.suit];
|
||||
return suit ? name + " of " + suit : face.label;
|
||||
}
|
||||
|
||||
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
|
||||
|
||||
// el builds one card. face === null means face-down: the card is on the table,
|
||||
// but this browser has not been told what it is.
|
||||
//
|
||||
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
|
||||
// it animates its own cards from wherever they actually came from,
|
||||
// and a board that re-renders would otherwise re-deal itself out of
|
||||
// the corner on every single move.
|
||||
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
|
||||
// solitaire column does not, because thirteen tilted cards stacked
|
||||
// an eighth of an inch apart read as a mistake rather than as a
|
||||
// hand that was handled.
|
||||
function el(face, opts) {
|
||||
opts = opts || {};
|
||||
var deal = opts.deal !== false;
|
||||
var tilt = opts.tilt !== false;
|
||||
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "pete-card";
|
||||
wrap.dataset.face = face ? "up" : "down";
|
||||
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
|
||||
|
||||
if (deal) {
|
||||
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||||
wrap.style.setProperty("--deal-x", "14rem");
|
||||
wrap.style.setProperty("--deal-y", "-6rem");
|
||||
} else {
|
||||
wrap.style.animation = "none";
|
||||
}
|
||||
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
|
||||
|
||||
var inner = document.createElement("div");
|
||||
inner.className = "pete-card-inner";
|
||||
|
||||
var front = document.createElement("div");
|
||||
front.className = "pete-card-front";
|
||||
var back = document.createElement("div");
|
||||
back.className = "pete-card-back";
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
wrap.appendChild(inner);
|
||||
if (face) paint(front, face);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||
function turnOver(wrap, face) {
|
||||
if (!wrap) return;
|
||||
paint(wrap.querySelector(".pete-card-front"), face);
|
||||
wrap.dataset.face = "up";
|
||||
wrap.dataset.key = face.label;
|
||||
}
|
||||
|
||||
window.PeteCards = {
|
||||
el: el,
|
||||
paint: paint,
|
||||
turnOver: turnOver,
|
||||
aria: aria,
|
||||
art: SUIT_ART,
|
||||
};
|
||||
})();
|
||||
340
internal/web/static/js/casino-fx.js
Normal file
340
internal/web/static/js/casino-fx.js
Normal file
@@ -0,0 +1,340 @@
|
||||
// The moving parts of the room.
|
||||
//
|
||||
// A casino is mostly one gesture repeated: something of value is picked up here
|
||||
// and put down there, in front of you, slowly enough that you can object. So the
|
||||
// only real idea in this file is fly() — take an element's place on screen, take
|
||||
// another's, and move a chip between them on an arc that a hand would make.
|
||||
//
|
||||
// Everything flies in a fixed overlay pinned to the viewport, not inside the
|
||||
// felt, because a chip's journey starts on a button *outside* the felt and any
|
||||
// container in between would clip it halfway. Coordinates come from
|
||||
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
|
||||
// and nothing has to be positioned relative to anything else.
|
||||
//
|
||||
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var reduced =
|
||||
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
var layer = null;
|
||||
function stage() {
|
||||
if (!layer) {
|
||||
layer = document.createElement("div");
|
||||
layer.className = "pete-fly-layer";
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
// Where a thing is, in viewport coordinates: the middle of it.
|
||||
function centre(target) {
|
||||
if (!target) return { x: 0, y: 0 };
|
||||
if (typeof target.x === "number") return target;
|
||||
var r = target.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}
|
||||
|
||||
// DENOMS, biggest first — the order you break an amount down in.
|
||||
var DENOMS = [500, 100, 25, 5];
|
||||
|
||||
// chipsFor turns an amount into the chips a dealer would actually push across:
|
||||
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
|
||||
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
|
||||
// just gets shorter than the number under it, which is what a real tall stack
|
||||
// looks like from across a table anyway.
|
||||
function chipsFor(amount, cap) {
|
||||
var out = [];
|
||||
var left = Math.max(0, Math.round(amount || 0));
|
||||
for (var i = 0; i < DENOMS.length && left > 0; i++) {
|
||||
while (left >= DENOMS[i]) {
|
||||
out.push(DENOMS[i]);
|
||||
left -= DENOMS[i];
|
||||
}
|
||||
}
|
||||
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
|
||||
var max = cap || 12;
|
||||
return out.length > max ? out.slice(0, max) : out;
|
||||
}
|
||||
|
||||
function disc(denom) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-disc";
|
||||
el.dataset.chip = String(denom);
|
||||
return el;
|
||||
}
|
||||
|
||||
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
|
||||
// reshuffle itself every time the page repaints.
|
||||
function jitter(i, spread) {
|
||||
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
|
||||
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
|
||||
}
|
||||
|
||||
// flyNode moves *anything* from one place to another and resolves when it
|
||||
// lands: a chip, a playing card, a card face down.
|
||||
//
|
||||
// The arc is the point. A straight line between two rects reads as a UI
|
||||
// transition; something that rises, travels and drops reads as a throw. WAAPI
|
||||
// does this in one keyframe list because it can interpolate a midpoint — a CSS
|
||||
// transition can't, which is why this isn't a class toggle.
|
||||
//
|
||||
// The node is yours: build it, hand it over, and it is gone when the promise
|
||||
// resolves. Nothing in here knows or cares what it was.
|
||||
function flyNode(node, from, to, opts) {
|
||||
opts = opts || {};
|
||||
var a = centre(from);
|
||||
var b = centre(to);
|
||||
node.classList.add("pete-fly");
|
||||
stage().appendChild(node);
|
||||
|
||||
var dur = opts.duration || 420;
|
||||
if (reduced) dur = 1;
|
||||
|
||||
// How high it goes: further throws arc higher, and a lob can be forced.
|
||||
var dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
|
||||
var midX = (a.x + b.x) / 2;
|
||||
var midY = (a.y + b.y) / 2 - lift;
|
||||
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
|
||||
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
|
||||
var s1 = opts.toScale == null ? 1 : opts.toScale;
|
||||
// Bigger at the top of the arc than at either end — that swell is what sells
|
||||
// the thing as coming towards you. Taken off the larger end, so a throw that
|
||||
// starts small and lands full size still peaks above where it lands, rather
|
||||
// than averaging itself back down to nothing.
|
||||
var sMid = Math.max(s0, s1) * 1.12;
|
||||
|
||||
var anim = node.animate(
|
||||
[
|
||||
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
|
||||
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
|
||||
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
|
||||
],
|
||||
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
|
||||
);
|
||||
|
||||
return anim.finished
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
node.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// fly throws one chip. It is flyNode with a chip in it, which is what it always
|
||||
// was — UNO wanted the same throw with a card in it, so the throw moved out.
|
||||
function fly(from, to, opts) {
|
||||
opts = opts || {};
|
||||
return flyNode(disc(opts.denom || 25), from, to, opts);
|
||||
}
|
||||
|
||||
function t(x, y, scale, rot) {
|
||||
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
|
||||
}
|
||||
|
||||
// flyMany throws a whole bet across, one chip after another rather than all at
|
||||
// once, because a pile of chips arriving as a single object is a progress bar.
|
||||
// Resolves when the last one lands.
|
||||
function flyMany(from, to, denoms, opts) {
|
||||
opts = opts || {};
|
||||
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
|
||||
var each = denoms.map(function (d, i) {
|
||||
return fly(from, to, {
|
||||
denom: d,
|
||||
index: i,
|
||||
delay: (opts.delay || 0) + i * gap,
|
||||
duration: opts.duration,
|
||||
lift: opts.lift,
|
||||
fade: opts.fade,
|
||||
spin: opts.spin,
|
||||
});
|
||||
});
|
||||
if (opts.onLand && !reduced) {
|
||||
denoms.forEach(function (d, i) {
|
||||
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
|
||||
});
|
||||
} else if (opts.onLand) {
|
||||
denoms.forEach(function (d, i) { opts.onLand(d, i); });
|
||||
}
|
||||
return Promise.all(each);
|
||||
}
|
||||
|
||||
// A bet spot: the pile of chips sitting on it, and the number printed under
|
||||
// the pile.
|
||||
//
|
||||
// The rule the whole room is built on lives in here, which is why it's one
|
||||
// object and not two variables on a table: **the number is a readout of the
|
||||
// pile, never the other way round.** There is no way to change one without the
|
||||
// other, so a settled game can't leave "your bet: 300" printed over an empty
|
||||
// circle, and a payout can't be counted before the chips that justify it have
|
||||
// landed.
|
||||
//
|
||||
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
|
||||
// holds what you've banked, which grows a card at a time. Same object.
|
||||
function spot(els) {
|
||||
var api = {
|
||||
// What the pile is holding. Written by render, and readable by a table that
|
||||
// needs to know whether a chip is already on its way down.
|
||||
amount: 0,
|
||||
|
||||
render: function (n) {
|
||||
api.amount = n || 0;
|
||||
els.stack.innerHTML = "";
|
||||
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
|
||||
if (!api.amount) {
|
||||
if (els.total) els.total.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
chipsFor(api.amount).forEach(function (d, i) {
|
||||
var c = disc(d);
|
||||
c.style.setProperty("--i", i);
|
||||
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
|
||||
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
|
||||
els.stack.appendChild(c);
|
||||
});
|
||||
if (els.total) {
|
||||
els.total.textContent = api.amount.toLocaleString();
|
||||
els.total.classList.remove("hidden");
|
||||
}
|
||||
},
|
||||
|
||||
// pour throws a run of chips onto the spot and grows the pile as each one
|
||||
// lands — by the value of the chip that landed, so the total under the pile
|
||||
// counts up the way the chips do. The last chip carries the remainder,
|
||||
// because chipsFor caps how many chips it will make you watch and the pile
|
||||
// still has to end on the real number.
|
||||
pour: function (from, amount, opts) {
|
||||
if (amount <= 0) return Promise.resolve();
|
||||
var base = api.amount;
|
||||
var chips = chipsFor(amount, 8);
|
||||
var run = 0;
|
||||
return flyMany(from, els.spot, chips, Object.assign({
|
||||
onLand: function (d, i) {
|
||||
run += d;
|
||||
api.render(base + (i === chips.length - 1 ? amount : run));
|
||||
},
|
||||
}, opts || {}));
|
||||
},
|
||||
|
||||
// sweep sends chips off the spot to somewhere else — your pile, or the
|
||||
// house's rack. The spot is emptied *now* rather than when they land, so
|
||||
// nothing that is already in the air can be bet a second time.
|
||||
sweep: function (to, amount, opts) {
|
||||
var n = amount == null ? api.amount : amount;
|
||||
var left = api.amount - n;
|
||||
if (n <= 0) return Promise.resolve();
|
||||
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
|
||||
api.render(left > 0 ? left : 0);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
// burst: confetti out of a point. Saved for the things worth celebrating.
|
||||
function burst(target, opts) {
|
||||
if (reduced) return Promise.resolve();
|
||||
opts = opts || {};
|
||||
var c = centre(target);
|
||||
var n = opts.count || 26;
|
||||
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
|
||||
var done = [];
|
||||
|
||||
for (var i = 0; i < n; i++) {
|
||||
var bit = document.createElement("div");
|
||||
bit.className = "pete-spark";
|
||||
bit.style.background = colours[i % colours.length];
|
||||
stage().appendChild(bit);
|
||||
|
||||
// Fired into a cone that mostly goes up, then let gravity have it.
|
||||
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
|
||||
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
|
||||
var vx = Math.cos(angle) * speed;
|
||||
var vy = Math.sin(angle) * speed;
|
||||
var dropTo = c.y + 220 + jitter(i + 3, 60);
|
||||
|
||||
done.push(
|
||||
bit
|
||||
.animate(
|
||||
[
|
||||
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
|
||||
{
|
||||
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
|
||||
opacity: 1,
|
||||
offset: 0.4,
|
||||
},
|
||||
// Held at full colour most of the way down: a piece of confetti that
|
||||
// starts fading the moment it's thrown reads as smoke.
|
||||
{
|
||||
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
|
||||
opacity: 1,
|
||||
offset: 0.75,
|
||||
},
|
||||
{
|
||||
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
|
||||
opacity: 0,
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
|
||||
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
|
||||
fill: "both",
|
||||
}
|
||||
)
|
||||
.finished.catch(function () {})
|
||||
.then(
|
||||
(function (b) {
|
||||
return function () { b.remove(); };
|
||||
})(bit)
|
||||
)
|
||||
);
|
||||
}
|
||||
return Promise.all(done);
|
||||
}
|
||||
|
||||
// count rolls a number to a new value instead of swapping it. A chip count that
|
||||
// jumps is a variable; one that climbs is a payout.
|
||||
function count(el, to, opts) {
|
||||
if (!el) return;
|
||||
opts = opts || {};
|
||||
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
|
||||
if (isNaN(from) || reduced || from === to) {
|
||||
el.textContent = (to || 0).toLocaleString();
|
||||
return;
|
||||
}
|
||||
var dur = opts.duration || 520;
|
||||
var t0 = null;
|
||||
if (el._petePop) el._petePop.cancel();
|
||||
el._petePop = el.animate(
|
||||
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
|
||||
{ duration: dur, easing: "ease-out" }
|
||||
);
|
||||
|
||||
function step(ts) {
|
||||
if (t0 === null) t0 = ts;
|
||||
var p = Math.min(1, (ts - t0) / dur);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else el.textContent = (to || 0).toLocaleString();
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
window.PeteFX = {
|
||||
reduced: reduced,
|
||||
chipsFor: chipsFor,
|
||||
disc: disc,
|
||||
jitter: jitter,
|
||||
fly: fly,
|
||||
flyNode: flyNode,
|
||||
flyMany: flyMany,
|
||||
spot: spot,
|
||||
burst: burst,
|
||||
count: count,
|
||||
centre: centre,
|
||||
};
|
||||
})();
|
||||
170
internal/web/static/js/games.js
Normal file
170
internal/web/static/js/games.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// The money bar: chips, wallet, buying in, cashing out.
|
||||
//
|
||||
// Buying chips is not instant and cannot be. gogobee owns the euros and has no
|
||||
// inbound API, so it polls Pete for the crossing, moves the money on its side,
|
||||
// and pushes the verdict back — a few seconds, end to end. So the button does
|
||||
// not lie about it: the chips show as "buying" until they are real, and this
|
||||
// file polls until they are. Nothing spendable appears until gogobee has said
|
||||
// it took the euros.
|
||||
//
|
||||
// Exposed as window.PeteGames so the table (blackjack.js) shares one view of
|
||||
// the money rather than keeping a second copy that drifts from this one.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var bar = document.querySelector("[data-chipbar]");
|
||||
if (!bar) return;
|
||||
|
||||
var chipsEl = bar.querySelector("[data-chips]");
|
||||
var pendingEl = bar.querySelector("[data-pending]");
|
||||
var eurosEl = bar.querySelector("[data-euros]");
|
||||
var amountEl = bar.querySelector("[data-buyin-amount]");
|
||||
var buyBtn = bar.querySelector("[data-buyin]");
|
||||
var cashBtn = bar.querySelector("[data-cashout]");
|
||||
var msgEl = bar.querySelector("[data-chipbar-msg]");
|
||||
|
||||
var listeners = [];
|
||||
var view = null;
|
||||
var pollTimer = null;
|
||||
var pollUntil = 0;
|
||||
|
||||
function money(n) {
|
||||
return (n || 0).toLocaleString();
|
||||
}
|
||||
|
||||
function say(text, tone) {
|
||||
if (!msgEl) return;
|
||||
if (!text) { msgEl.classList.add("hidden"); return; }
|
||||
msgEl.textContent = text;
|
||||
msgEl.classList.remove("hidden");
|
||||
msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
function paint(v) {
|
||||
var first = view === null;
|
||||
view = v;
|
||||
// The chip count rolls to its new value rather than jumping to it — the table
|
||||
// times this to land with the chips that caused it, so a payout reads as the
|
||||
// number catching up with the felt. On the first paint there's nothing to
|
||||
// catch up with, so it just sets.
|
||||
if (chipsEl) {
|
||||
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
|
||||
else window.PeteFX.count(chipsEl, v.chips);
|
||||
}
|
||||
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
|
||||
|
||||
if (pendingEl) {
|
||||
if (v.pending > 0) {
|
||||
pendingEl.textContent = "+" + money(v.pending) + " buying…";
|
||||
pendingEl.classList.remove("hidden");
|
||||
} else {
|
||||
pendingEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// You cannot cash out mid-game: the stake is already on the table. `game` is
|
||||
// set by whichever game you're in, so this holds for any of them — checking
|
||||
// for a blackjack hand specifically would let you walk out on a hangman.
|
||||
if (cashBtn) cashBtn.disabled = v.chips <= 0 || !!v.game;
|
||||
if (buyBtn) buyBtn.disabled = v.chips + v.pending >= v.cap;
|
||||
|
||||
listeners.forEach(function (fn) { fn(v); });
|
||||
}
|
||||
|
||||
// pollPending keeps asking while a buy-in is in flight, and stops the moment
|
||||
// it lands — or is refused, which looks the same from here (pending drops to
|
||||
// zero) and is told apart by whether the chips arrived.
|
||||
function pollPending() {
|
||||
clearTimeout(pollTimer);
|
||||
if (Date.now() > pollUntil) {
|
||||
say("gogobee hasn't answered yet. Your euros are safe — give it a moment and reload.");
|
||||
return;
|
||||
}
|
||||
pollTimer = setTimeout(function () {
|
||||
get().then(function (v) {
|
||||
if (!v) return;
|
||||
if (v.pending > 0) { pollPending(); return; }
|
||||
if (v.chips > (pollPending.was || 0)) {
|
||||
say("Chips are yours. Good luck!");
|
||||
} else {
|
||||
say("gogobee wouldn't cover that — not enough euros in your wallet.", "bad");
|
||||
}
|
||||
});
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function request(path, body) {
|
||||
return fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (data) {
|
||||
if (!res.ok) throw new Error(data.error || "that didn't work");
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function get() {
|
||||
return fetch("/api/games/table", { headers: { "Accept": "application/json" } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (v) { if (v) paint(v); return v; })
|
||||
.catch(function () { return null; });
|
||||
}
|
||||
|
||||
if (buyBtn) {
|
||||
buyBtn.addEventListener("click", function () {
|
||||
var amount = parseInt(amountEl && amountEl.value, 10);
|
||||
if (!(amount > 0)) { say("How many chips?", "bad"); return; }
|
||||
|
||||
buyBtn.disabled = true;
|
||||
say("Asking gogobee for " + money(amount) + " euros…");
|
||||
pollPending.was = view ? view.chips : 0;
|
||||
|
||||
request("/api/games/buyin", { amount: amount })
|
||||
.then(function (v) {
|
||||
paint(v);
|
||||
pollUntil = Date.now() + 60000;
|
||||
pollPending();
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad");
|
||||
buyBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (cashBtn) {
|
||||
cashBtn.addEventListener("click", function () {
|
||||
cashBtn.disabled = true;
|
||||
say("Cashing you out…");
|
||||
request("/api/games/cashout", { amount: 0 })
|
||||
.then(function (v) {
|
||||
paint(v);
|
||||
say("Chips are on their way back to euros. They'll show in your wallet shortly.");
|
||||
// The euro balance Pete shows is whatever gogobee last told it, so it
|
||||
// only moves once the credit has actually gone through over there.
|
||||
setTimeout(get, 4000);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad");
|
||||
cashBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.PeteGames = {
|
||||
// onUpdate registers a listener called on every fresh view of the money.
|
||||
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
|
||||
// apply pushes a view the table already fetched (a deal answers with one),
|
||||
// so playing a hand doesn't need a second round-trip to refresh the chips.
|
||||
apply: paint,
|
||||
refresh: get,
|
||||
post: request,
|
||||
say: say,
|
||||
view: function () { return view; },
|
||||
};
|
||||
|
||||
get();
|
||||
})();
|
||||
555
internal/web/static/js/hangman.js
Normal file
555
internal/web/static/js/hangman.js
Normal file
@@ -0,0 +1,555 @@
|
||||
// The hangman table.
|
||||
//
|
||||
// Same bargain as the blackjack table: the browser holds no game. It sends a
|
||||
// letter, and the server answers with the board you're allowed to see — the
|
||||
// phrase with the letters you haven't earned still blank — plus the script of
|
||||
// what just happened. The phrase itself only ever arrives once the game is over
|
||||
// and it no longer decides anything.
|
||||
//
|
||||
// The gallows is the meter. It counts your lives down and your winnings down at
|
||||
// the same time, which is why a miss draws a limb *and* knocks the multiple back
|
||||
// in the same beat: they are the same event, and showing them as one thing is
|
||||
// the whole reason to bet on this rather than play it on paper.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-hangman]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var boardEl = root.querySelector("[data-board]");
|
||||
var gallowsEl = root.querySelector("[data-gallows]");
|
||||
var wrongEl = root.querySelector("[data-wrong]");
|
||||
var wrongLbl = root.querySelector("[data-wrong-label]");
|
||||
var multEl = root.querySelector("[data-multiple]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
var standsEl = root.querySelector("[data-stands]");
|
||||
var standsLbl = root.querySelector("[data-stands-label]");
|
||||
var livesEl = root.querySelector("[data-lives]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var guessing = root.querySelector("[data-guessing]");
|
||||
var keysEl = root.querySelector("[data-keyboard]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var solveIn = root.querySelector("[data-solve-input]");
|
||||
var solveBtn = root.querySelector("[data-solve]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||
|
||||
// The three places a chip can be, exactly as at the other table.
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// The bet spot, and the rule that comes with it: the number under the pile is a
|
||||
// readout of the pile, never the other way round.
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var bet = 0; // what you're building between games
|
||||
var busy = false;
|
||||
var game = null; // the board as the server last described it
|
||||
var tier = "medium";
|
||||
|
||||
var FLIP_MS = 320;
|
||||
var MISS_MS = 520;
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone, where) {
|
||||
var el = where || msgEl;
|
||||
if (!el) return;
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- the board -------------------------------------------------------------
|
||||
|
||||
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
|
||||
// a space or a piece of punctuation is scaffolding and gets no tile, because a
|
||||
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||
//
|
||||
// Words are kept whole: the board wraps between words, never inside one.
|
||||
function renderBoard(cells) {
|
||||
boardEl.innerHTML = "";
|
||||
if (!cells) return;
|
||||
|
||||
var word = document.createElement("div");
|
||||
word.className = "pete-word";
|
||||
|
||||
cells.forEach(function (c, i) {
|
||||
if (!c.slot && (c.ch === " " || c.ch === "")) {
|
||||
// A space: end the word and start the next one.
|
||||
if (word.childNodes.length) boardEl.appendChild(word);
|
||||
word = document.createElement("div");
|
||||
word.className = "pete-word";
|
||||
return;
|
||||
}
|
||||
var t = document.createElement("span");
|
||||
t.className = "pete-tile";
|
||||
t.dataset.at = String(i);
|
||||
if (!c.slot) {
|
||||
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
|
||||
t.textContent = c.ch;
|
||||
} else {
|
||||
t.dataset.up = c.ch ? "1" : "0";
|
||||
t.textContent = c.ch || "";
|
||||
}
|
||||
word.appendChild(t);
|
||||
});
|
||||
if (word.childNodes.length) boardEl.appendChild(word);
|
||||
}
|
||||
|
||||
// turnUp flips the tiles a hit just earned, one after the other so a letter that
|
||||
// appears three times reads as three finds rather than one repaint.
|
||||
function turnUp(at, ch) {
|
||||
if (!at || !at.length) return Promise.resolve();
|
||||
at.forEach(function (i, n) {
|
||||
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
|
||||
if (!t) return;
|
||||
setTimeout(function () {
|
||||
// Left as it comes: the tile is uppercased in CSS, and doing it here too
|
||||
// would mean the resume path (which paints the phrase's own casing) and
|
||||
// this one put different text in the same tile.
|
||||
t.textContent = ch;
|
||||
t.dataset.up = "1";
|
||||
t.classList.add("pete-tile-hit");
|
||||
}, pace(n * 90));
|
||||
});
|
||||
return wait(FLIP_MS + (at.length - 1) * 90);
|
||||
}
|
||||
|
||||
// ---- the gallows -----------------------------------------------------------
|
||||
|
||||
// drawGallows shows the first n parts. Each one draws itself in along its own
|
||||
// length rather than fading up — the difference between a limb being *drawn* and
|
||||
// a limb appearing is the whole character of the game.
|
||||
function drawGallows(n, animateLast) {
|
||||
var parts = gallowsEl.querySelectorAll("[data-part]");
|
||||
parts.forEach(function (p, i) {
|
||||
var on = i < n;
|
||||
var was = p.dataset.on === "1";
|
||||
p.dataset.on = on ? "1" : "0";
|
||||
if (on && !was && animateLast && i === n - 1 && !reduced) {
|
||||
// Restart the draw-in animation on the part that was just earned.
|
||||
p.classList.remove("pete-part-draw");
|
||||
void p.getBoundingClientRect();
|
||||
p.classList.add("pete-part-draw");
|
||||
} else if (on && !animateLast) {
|
||||
p.classList.remove("pete-part-draw");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shake() {
|
||||
if (reduced) return;
|
||||
gallowsEl.classList.remove("pete-shake");
|
||||
void gallowsEl.getBoundingClientRect();
|
||||
gallowsEl.classList.add("pete-shake");
|
||||
}
|
||||
|
||||
// ---- the meter -------------------------------------------------------------
|
||||
|
||||
function renderMeter(v) {
|
||||
if (!v) {
|
||||
multEl.textContent = "—";
|
||||
standsEl.textContent = "—";
|
||||
standsLbl.textContent = "if you get it";
|
||||
livesEl.textContent = "";
|
||||
meterEl.dataset.cold = "0";
|
||||
return;
|
||||
}
|
||||
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||||
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||||
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
|
||||
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
|
||||
// The meter goes cold once the multiple is down at its floor: from here a win
|
||||
// hands back the stake and nothing more.
|
||||
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
|
||||
}
|
||||
|
||||
// knock ticks the multiple down to its new value, so the number falls rather
|
||||
// than simply being different.
|
||||
function knock(v) {
|
||||
if (reduced) { renderMeter(v); return; }
|
||||
var from = parseFloat(multEl.textContent) || v.multiple;
|
||||
var to = v.multiple;
|
||||
var t0 = performance.now();
|
||||
meterEl.dataset.hit = "1";
|
||||
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
|
||||
|
||||
(function step(now) {
|
||||
var p = Math.min(1, (now - t0) / 380);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else renderMeter(v);
|
||||
})(t0);
|
||||
}
|
||||
|
||||
// ---- the keyboard ----------------------------------------------------------
|
||||
|
||||
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
|
||||
|
||||
function buildKeys() {
|
||||
keysEl.innerHTML = "";
|
||||
ROWS.forEach(function (row, r) {
|
||||
var line = document.createElement("div");
|
||||
line.className = "pete-key-row";
|
||||
if (r === 3) line.dataset.digits = "1";
|
||||
row.split("").forEach(function (ch) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "pete-key";
|
||||
b.dataset.key = ch;
|
||||
b.textContent = ch.toUpperCase();
|
||||
b.addEventListener("click", function () { guessLetter(ch); });
|
||||
line.appendChild(b);
|
||||
});
|
||||
keysEl.appendChild(line);
|
||||
});
|
||||
}
|
||||
|
||||
// paintKeys marks every letter that's been tried, and how it went. A key that
|
||||
// has been spent should look spent — otherwise you spend it again.
|
||||
function paintKeys(v) {
|
||||
var tried = (v && v.tried) || [];
|
||||
var wrong = (v && v.wrong) || [];
|
||||
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
|
||||
var ch = b.dataset.key;
|
||||
var used = tried.indexOf(ch) !== -1;
|
||||
b.disabled = used || !v || v.phase !== "playing";
|
||||
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
|
||||
});
|
||||
}
|
||||
|
||||
function renderWrong(v) {
|
||||
wrongEl.innerHTML = "";
|
||||
var wrong = (v && v.wrong) || [];
|
||||
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
|
||||
// and it's on the gallows, but there's no key to grey out for it.
|
||||
var letters = wrong.filter(function (c) { return c !== "·"; });
|
||||
wrongLbl.classList.toggle("hidden", letters.length === 0);
|
||||
letters.forEach(function (ch) {
|
||||
var s = document.createElement("span");
|
||||
s.className = "pete-missed";
|
||||
s.textContent = ch.toUpperCase();
|
||||
wrongEl.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the money on the felt -------------------------------------------------
|
||||
// The spot is PeteFX's, the same one every other table in the room bets onto: a
|
||||
// chip has to behave the same way in both rooms or it isn't a chip, it's a
|
||||
// widget.
|
||||
|
||||
function stake(amount, from) {
|
||||
return spot.pour(from || purseEl, amount);
|
||||
}
|
||||
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
var back = payout - final.bet;
|
||||
|
||||
if (payout <= 0) {
|
||||
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
}
|
||||
// Paid into the spot beside your stake, then the whole lot swept home.
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
// ---- phases ----------------------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
solved: "Got it! 🎉",
|
||||
filled: "That's the lot!",
|
||||
hung: "Hung.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
|
||||
// Confetti for a phrase guessed outright — the one call you make on your own
|
||||
// rather than by grinding the alphabet.
|
||||
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase === "playing";
|
||||
betting.classList.toggle("hidden", live);
|
||||
guessing.classList.toggle("hidden", !live);
|
||||
paintKeys(v);
|
||||
if (!live && solveIn) solveIn.value = "";
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// paint puts a board up with no animation: the resume path, after a reload or a
|
||||
// redeploy. Your stake is still on the spot because the server still has it.
|
||||
function paint(v) {
|
||||
if (!v) {
|
||||
renderBoard(null);
|
||||
drawGallows(0, false);
|
||||
renderWrong(null);
|
||||
renderMeter(null);
|
||||
spot.render(0);
|
||||
setPhase(null);
|
||||
return;
|
||||
}
|
||||
renderBoard(v.cells);
|
||||
drawGallows((v.wrong || []).length, false);
|
||||
renderWrong(v);
|
||||
renderMeter(v);
|
||||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||
setPhase(v);
|
||||
}
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// play walks the server's events. Same rule as the other table: on a live game
|
||||
// the money is already right (your stake left your pile when you pressed Play,
|
||||
// and it's on the spot), but on a settling one the chip bar is held back until
|
||||
// the chips have physically come home.
|
||||
function play(view, money) {
|
||||
var events = view.hang_events || [];
|
||||
var final = view.hangman;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var chain = Promise.resolve();
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
if (final && final.bet > spot.amount) {
|
||||
var extra = final.bet - spot.amount;
|
||||
chain = chain.then(function () { return stake(extra); });
|
||||
}
|
||||
|
||||
events.forEach(function (e) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "start":
|
||||
verdictEl.classList.add("hidden");
|
||||
renderBoard(final && final.cells);
|
||||
drawGallows(0, false);
|
||||
renderWrong(null);
|
||||
renderMeter(final);
|
||||
return;
|
||||
|
||||
case "hit":
|
||||
return turnUp(e.at, e.letter);
|
||||
|
||||
case "miss":
|
||||
// The limb, the shake and the multiple falling are one event, because
|
||||
// they are one event: this is what a wrong guess costs, all of it.
|
||||
drawGallows(countMisses(events, e), true);
|
||||
shake();
|
||||
if (final) knock(final);
|
||||
return wait(MISS_MS);
|
||||
|
||||
case "solve":
|
||||
return wait(220);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
renderWrong(final);
|
||||
if (!settles) {
|
||||
renderMeter(final);
|
||||
setPhase(final);
|
||||
return;
|
||||
}
|
||||
|
||||
// Over: the board goes fully face up (the server has finally sent the
|
||||
// phrase), then the money moves, and only then does the bar catch up.
|
||||
guessing.classList.add("hidden");
|
||||
renderBoard(final.cells);
|
||||
renderMeter(final);
|
||||
verdict(final);
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// countMisses works out how many limbs should be on the gallows by the time
|
||||
// this miss has been played — the misses already on the board when the request
|
||||
// went out, plus every miss in this batch up to and including this one.
|
||||
function countMisses(events, upTo) {
|
||||
var before = game ? (game.wrong || []).length : 0;
|
||||
var n = 0;
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (events[i].kind === "miss") n++;
|
||||
if (events[i] === upTo) break;
|
||||
}
|
||||
return before + n;
|
||||
}
|
||||
|
||||
// standing puts the stake back on the spot for the next phrase, the way the
|
||||
// blackjack table leaves your bet up.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
return stake(amount);
|
||||
}
|
||||
|
||||
// ---- talking to the table ---------------------------------------------------
|
||||
|
||||
function send(path, body, where) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("", null, where);
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad", where);
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && !v.hangman) spot.render(0);
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
function guessLetter(ch) {
|
||||
if (busy || !game || game.phase !== "playing") return;
|
||||
if ((game.tried || []).indexOf(ch) !== -1) return;
|
||||
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
|
||||
}
|
||||
|
||||
// ---- betting ----------------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (startBtn) startBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
function pickTier(slug) {
|
||||
tier = slug;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickTier(b.dataset.tier);
|
||||
});
|
||||
});
|
||||
|
||||
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
||||
// rack, and the house is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
send("/api/games/hangman/start", { bet: bet, tier: tier });
|
||||
});
|
||||
}
|
||||
|
||||
function solve() {
|
||||
if (busy || !game || game.phase !== "playing") return;
|
||||
var attempt = (solveIn.value || "").trim();
|
||||
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
|
||||
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
|
||||
}
|
||||
|
||||
if (solveBtn) solveBtn.addEventListener("click", solve);
|
||||
if (solveIn) {
|
||||
solveIn.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); solve(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Type a letter to guess it — but not while you're typing a solution into the
|
||||
// box, which is the whole reason this checks what has focus.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||
if (!game || game.phase !== "playing" || busy) return;
|
||||
var ch = (e.key || "").toLowerCase();
|
||||
if (!/^[a-z0-9]$/.test(ch)) return;
|
||||
e.preventDefault();
|
||||
guessLetter(ch);
|
||||
});
|
||||
|
||||
buildKeys();
|
||||
pickTier(tier);
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.hangman) {
|
||||
paint(v.hangman);
|
||||
if (v.hangman.phase === "done") verdict(v.hangman);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
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();
|
||||
});
|
||||
})();
|
||||
729
internal/web/static/js/solitaire.js
Normal file
729
internal/web/static/js/solitaire.js
Normal file
@@ -0,0 +1,729 @@
|
||||
// The solitaire table.
|
||||
//
|
||||
// Blackjack plays back a *script*: the server sends one event per card off the
|
||||
// shoe and the table deals them out in order. That works because a blackjack hand
|
||||
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
|
||||
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
|
||||
// move can turn a card over three columns away. A script of "append this card
|
||||
// there" would be a second engine over here, and it would be the one that's wrong.
|
||||
//
|
||||
// So this table re-renders the whole board from the server's view after every
|
||||
// move, and then animates the difference — FLIP: measure where every card *was*,
|
||||
// re-render, measure where it *is*, and play each card from its old place to its
|
||||
// new one. The board on screen is therefore always exactly the board the server
|
||||
// says exists, and the animation is derived from it rather than the other way
|
||||
// round. The events are still used, for the two things a diff genuinely cannot
|
||||
// tell you: where a newly-revealed card came from (out of the stock, or turned
|
||||
// over in place), and what the board is now worth.
|
||||
//
|
||||
// The money follows the same rule as every other table in the room: nothing about
|
||||
// it changes without a chip crossing the felt to make it change. Here the stake
|
||||
// buys the deck — it goes to the house and does not come back — and the spot in
|
||||
// the rail holds what you've *banked*, which grows by one card's worth every time
|
||||
// a card reaches a foundation and shrinks again if you take one back off.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-solitaire]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
var CARDS = window.PeteCards;
|
||||
|
||||
var stockEl = root.querySelector("[data-stock]");
|
||||
var stockCountEl = root.querySelector("[data-stock-count]");
|
||||
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
|
||||
var wasteEl = root.querySelector("[data-waste]");
|
||||
var foundEl = root.querySelector("[data-foundations]");
|
||||
var tableauEl = root.querySelector("[data-tableau]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
var homeEl = root.querySelector("[data-home]");
|
||||
var perCardEl = root.querySelector("[data-per-card]");
|
||||
var breakEvenEl = root.querySelector("[data-break-even]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var autoBtn = root.querySelector("[data-auto]");
|
||||
var cashBtn = root.querySelector("[data-cash]");
|
||||
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var betAmountEl = root.querySelector("[data-bet-amount]");
|
||||
var gameMsg = root.querySelector("[data-game-msg]");
|
||||
var tableMsg = root.querySelector("[data-table-msg]");
|
||||
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// The spot in the rail. On this table it holds what you've banked, not a bet.
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var FULL = 52;
|
||||
var MOVE_MS = 300; // one card's journey across the board
|
||||
var STEP_MS = 95; // the gap between two cards in a cascade
|
||||
var reduced = FX.reduced;
|
||||
|
||||
var bet = 0; // the deck you're building the price of
|
||||
var tier = null; // which deal
|
||||
var busy = false; // a request is in flight, or cards are still moving
|
||||
var board = null; // the board as the server last described it
|
||||
var held = null; // {pile, count, cards} — the run in your hand
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
|
||||
}
|
||||
|
||||
function say(el, text, tone) {
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- the rules, as the *browser* understands them --------------------------
|
||||
//
|
||||
// These mirror the engine, and they are not the engine: the server decides every
|
||||
// move and will refuse one this file thought was fine. They exist because you
|
||||
// cannot light up the columns a card can go to without knowing which those are,
|
||||
// and a table that makes you find that out by being told no is a table that
|
||||
// teaches Klondike by refusal. When the two disagree the server wins and the
|
||||
// board snaps back to whatever it says — see send().
|
||||
|
||||
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
|
||||
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
|
||||
|
||||
// isRun: descending by one, alternating colour — the only thing you may lift
|
||||
// off a column as a block.
|
||||
function isRun(cs) {
|
||||
for (var i = 1; i < cs.length; i++) {
|
||||
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// accepts: what may be put where. A foundation takes its own suit in order from
|
||||
// the ace; a column takes a run that descends and alternates from its top card,
|
||||
// and an empty column takes a King and nothing else.
|
||||
function accepts(pile, cs) {
|
||||
if (!board || !cs || !cs.length) return false;
|
||||
|
||||
if (pile.charAt(0) === "f") {
|
||||
var f = board.found[parseInt(pile.slice(1), 10)];
|
||||
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
|
||||
}
|
||||
|
||||
var col = board.table[parseInt(pile.slice(1), 10)];
|
||||
if (!col || !isRun(cs)) return false;
|
||||
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
|
||||
var top = col.up[col.up.length - 1];
|
||||
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
|
||||
}
|
||||
|
||||
// cardsAt is the run you'd be picking up by clicking this card.
|
||||
function cardsAt(pile, idx) {
|
||||
if (!board) return null;
|
||||
if (pile === "waste") {
|
||||
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
|
||||
}
|
||||
if (pile.charAt(0) === "f") {
|
||||
var f = board.found[parseInt(pile.slice(1), 10)];
|
||||
return f && f.top ? [f.top] : null;
|
||||
}
|
||||
var col = board.table[parseInt(pile.slice(1), 10)];
|
||||
if (!col || !col.up || idx >= col.up.length) return null;
|
||||
return col.up.slice(idx);
|
||||
}
|
||||
|
||||
// ---- drawing the board -----------------------------------------------------
|
||||
|
||||
// face builds a card element wired up for clicking. No deal flight and no tilt:
|
||||
// this table animates its own cards from wherever they actually came from, and a
|
||||
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
|
||||
// mistake rather than as a hand that was handled.
|
||||
function face(c, pile, idx) {
|
||||
var el = CARDS.el(c, { deal: false, tilt: false });
|
||||
el.dataset.pile = pile;
|
||||
el.dataset.idx = String(idx);
|
||||
return el;
|
||||
}
|
||||
|
||||
function slot(pile, glyph, red) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-slot";
|
||||
el.dataset.pile = pile;
|
||||
if (glyph) {
|
||||
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function render(v) {
|
||||
board = v;
|
||||
if (!v) {
|
||||
wasteEl.innerHTML = "";
|
||||
foundEl.innerHTML = "";
|
||||
tableauEl.innerHTML = "";
|
||||
stockEl.dataset.dead = "1";
|
||||
stockCountEl.classList.add("hidden");
|
||||
stockRecycleEl.classList.add("hidden");
|
||||
meter(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// The stock. Empty with a pass left is not the same thing as empty with none:
|
||||
// one is a gesture you can still make and the other is a dead pile, and the
|
||||
// difference is worth showing rather than leaving to a click that gets refused.
|
||||
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
|
||||
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
|
||||
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
|
||||
stockCountEl.textContent = String(v.stock);
|
||||
stockCountEl.classList.toggle("hidden", v.stock === 0);
|
||||
stockRecycleEl.classList.toggle("hidden", !canRecycle);
|
||||
|
||||
// The waste: the last three, fanned, and only the top one is yours to take.
|
||||
wasteEl.innerHTML = "";
|
||||
(v.waste || []).forEach(function (c, i, all) {
|
||||
var el = face(c, "waste", i);
|
||||
if (i === all.length - 1) el.dataset.live = "1";
|
||||
wasteEl.appendChild(el);
|
||||
});
|
||||
|
||||
// The foundations. Each is a slot that stays put whether or not there's a card
|
||||
// on it, so the board doesn't reflow the moment an ace goes home — and so a
|
||||
// drop target has somewhere to be even when it's empty.
|
||||
foundEl.innerHTML = "";
|
||||
v.found.forEach(function (f, i) {
|
||||
var s = slot("f" + i, f.suit, f.red);
|
||||
if (f.top) {
|
||||
var el = face(f.top, "f" + i, 0);
|
||||
el.dataset.live = "1";
|
||||
s.appendChild(el);
|
||||
}
|
||||
foundEl.appendChild(s);
|
||||
});
|
||||
|
||||
// The seven columns.
|
||||
tableauEl.innerHTML = "";
|
||||
v.table.forEach(function (col, i) {
|
||||
var c = document.createElement("div");
|
||||
c.className = "pete-col";
|
||||
c.dataset.pile = "t" + i;
|
||||
|
||||
if (!col.down && !(col.up && col.up.length)) {
|
||||
c.appendChild(slot("t" + i));
|
||||
}
|
||||
for (var d = 0; d < col.down; d++) {
|
||||
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
|
||||
}
|
||||
(col.up || []).forEach(function (card, j) {
|
||||
var el = face(card, "t" + i, j);
|
||||
// A card is pickable if the run from it down is a run. Anything else is a
|
||||
// card you can see and can't lift, and it shouldn't offer.
|
||||
if (isRun(col.up.slice(j))) el.dataset.live = "1";
|
||||
c.appendChild(el);
|
||||
});
|
||||
tableauEl.appendChild(c);
|
||||
});
|
||||
|
||||
meter(v);
|
||||
controls(v);
|
||||
if (held) mark(); // a selection survives a re-render, so redraw what it lit
|
||||
}
|
||||
|
||||
// meter is what the board is worth. Every number in it comes off the server —
|
||||
// this file does no arithmetic about money, which is the point.
|
||||
function meter(v) {
|
||||
if (!v) {
|
||||
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
|
||||
perCardEl.textContent = "—";
|
||||
breakEvenEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
|
||||
perCardEl.textContent = "+" + v.per_card.toFixed(1);
|
||||
breakEvenEl.textContent =
|
||||
v.home >= v.break_even
|
||||
? "You're ahead of the house"
|
||||
: v.break_even - v.home + " more to break even";
|
||||
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
|
||||
}
|
||||
|
||||
function controls(v) {
|
||||
var live = !!v && v.phase === "playing";
|
||||
playing.classList.toggle("hidden", !live);
|
||||
betting.classList.toggle("hidden", live);
|
||||
if (!live) return;
|
||||
autoBtn.disabled = !v.can_auto;
|
||||
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- FLIP ------------------------------------------------------------------
|
||||
//
|
||||
// Where every card is, right now. One deck, so a card's label is its identity.
|
||||
|
||||
function snapshot() {
|
||||
var map = {};
|
||||
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
||||
map[el.dataset.key] = el.getBoundingClientRect();
|
||||
});
|
||||
map["#stock"] = stockEl.getBoundingClientRect();
|
||||
return map;
|
||||
}
|
||||
|
||||
// plan reads the events for the two things a before/after diff can't tell you:
|
||||
// where a card that is *new to the board* came from, and how much of a beat to
|
||||
// leave before it moves, so that an auto-finish cascades rather than teleporting.
|
||||
function planOf(events) {
|
||||
var origins = {}, delays = {}, at = 0;
|
||||
(events || []).forEach(function (e) {
|
||||
(e.cards || []).forEach(function (c) {
|
||||
if (e.kind === "draw") origins[c.label] = "draw";
|
||||
if (e.kind === "flip") origins[c.label] = "flip";
|
||||
delays[c.label] = at;
|
||||
});
|
||||
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
|
||||
});
|
||||
return { origins: origins, delays: delays };
|
||||
}
|
||||
|
||||
// animate plays every card from where it was to where it is.
|
||||
function animate(before, plan) {
|
||||
if (reduced) return Promise.resolve();
|
||||
var waits = [];
|
||||
|
||||
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
||||
var key = el.dataset.key;
|
||||
var now = el.getBoundingClientRect();
|
||||
var was = before[key];
|
||||
var delay = plan.delays[key] || 0;
|
||||
var origin = plan.origins[key];
|
||||
|
||||
// A card that was already on the board: play it from its old place. This is
|
||||
// every ordinary move, and it is also what makes an eleven-card auto-finish
|
||||
// animate itself for free.
|
||||
if (was && !origin) {
|
||||
var dx = was.left - now.left;
|
||||
var dy = was.top - now.top;
|
||||
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
|
||||
waits.push(slide(el, dx, dy, delay));
|
||||
return;
|
||||
}
|
||||
|
||||
// A card that has just been turned over. Out of the stock it flies as well as
|
||||
// turns; in a column it turns where it lies.
|
||||
if (origin === "draw") {
|
||||
var from = before["#stock"];
|
||||
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
|
||||
waits.push(turn(el, delay));
|
||||
} else if (origin === "flip") {
|
||||
waits.push(turn(el, delay));
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(waits);
|
||||
}
|
||||
|
||||
function slide(el, dx, dy, delay) {
|
||||
return el.animate(
|
||||
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
|
||||
{
|
||||
duration: MOVE_MS,
|
||||
delay: delay,
|
||||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
fill: "backwards",
|
||||
}
|
||||
).finished.catch(noop);
|
||||
}
|
||||
|
||||
// The card turns over on its own axis. The wrapper is doing the travelling, so
|
||||
// this has to be the inner face or the two transforms would fight.
|
||||
function turn(el, delay) {
|
||||
var inner = el.querySelector(".pete-card-inner");
|
||||
return inner.animate(
|
||||
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
|
||||
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
|
||||
).finished.catch(noop);
|
||||
}
|
||||
|
||||
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
|
||||
// to animate: they're gone from the new render before it can measure them. They
|
||||
// get their flight here instead, out of the old DOM, before it's replaced.
|
||||
function recycleOut() {
|
||||
if (reduced) return Promise.resolve();
|
||||
var to = stockEl.getBoundingClientRect();
|
||||
var waits = [];
|
||||
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
|
||||
var now = el.getBoundingClientRect();
|
||||
waits.push(
|
||||
el.animate(
|
||||
[
|
||||
{ transform: "none", opacity: 1 },
|
||||
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
|
||||
],
|
||||
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
|
||||
).finished.catch(noop)
|
||||
);
|
||||
});
|
||||
return Promise.all(waits);
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
// A card reaching a foundation is the only move in this game that pays you, so
|
||||
// it's the only one that makes a noise about it.
|
||||
function flashHome(events) {
|
||||
if (reduced) return;
|
||||
var at = 0;
|
||||
(events || []).forEach(function (e) {
|
||||
if (e.kind !== "home" || !e.to) {
|
||||
if (e.kind === "move") at += STEP_MS;
|
||||
return;
|
||||
}
|
||||
var when = at + MOVE_MS;
|
||||
at += STEP_MS;
|
||||
setTimeout(function () {
|
||||
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
|
||||
if (!pile) return;
|
||||
pile.classList.remove("pete-home-flash");
|
||||
void pile.offsetWidth; // restart the animation if it's still running
|
||||
pile.classList.add("pete-home-flash");
|
||||
}, when);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// bank moves the spot to what the server says the board is worth. Up is chips
|
||||
// out of the house's rack; down — a card taken back off a foundation — is chips
|
||||
// going back to it. Either way the pile moves before the number does.
|
||||
function bank(pays) {
|
||||
var delta = (pays || 0) - spot.amount;
|
||||
if (delta === 0) return Promise.resolve();
|
||||
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
|
||||
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
|
||||
}
|
||||
|
||||
// ---- picking cards up ------------------------------------------------------
|
||||
|
||||
function mark() {
|
||||
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
|
||||
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
|
||||
if (!held) return;
|
||||
|
||||
// The run in your hand lifts off the felt.
|
||||
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
|
||||
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
|
||||
if (el.dataset.live === "1") el.dataset.held = "1";
|
||||
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
|
||||
el.dataset.held = "1";
|
||||
}
|
||||
});
|
||||
|
||||
// And everywhere it could go lights up. This is the whole reason the rules are
|
||||
// mirrored over here: being shown where a card goes is the game teaching you,
|
||||
// and being told no after you commit is the game scolding you.
|
||||
root.querySelectorAll("[data-pile]").forEach(function (el) {
|
||||
var pile = el.dataset.pile;
|
||||
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
|
||||
if (accepts(pile, held.cards)) el.dataset.drop = "1";
|
||||
});
|
||||
}
|
||||
|
||||
function pick(pile, idx) {
|
||||
var cs = cardsAt(pile, idx);
|
||||
if (!cs || !isRun(cs)) return;
|
||||
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
|
||||
mark();
|
||||
}
|
||||
|
||||
function drop() {
|
||||
held = null;
|
||||
mark();
|
||||
}
|
||||
|
||||
function nope(el) {
|
||||
if (!el || reduced) return;
|
||||
el.classList.remove("pete-nope");
|
||||
void el.offsetWidth;
|
||||
el.classList.add("pete-nope");
|
||||
}
|
||||
|
||||
// A click on the board. The order matters: if something is in your hand and the
|
||||
// thing you clicked will take it, that's a move — otherwise it's you picking up
|
||||
// something else. Which means you never have to put a card down before choosing
|
||||
// a different one.
|
||||
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
||||
if (busy || !board || board.phase !== "playing") return;
|
||||
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
||||
|
||||
var pileEl = e.target.closest("[data-pile]");
|
||||
if (!pileEl) { drop(); return; }
|
||||
|
||||
var cardEl = e.target.closest(".pete-card[data-key]");
|
||||
var pile = pileEl.dataset.pile;
|
||||
|
||||
if (held) {
|
||||
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
|
||||
if (accepts(pile, held.cards)) {
|
||||
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
|
||||
drop();
|
||||
send(move);
|
||||
return;
|
||||
}
|
||||
// Not a place it goes. If what you clicked is a card you *could* pick up,
|
||||
// this was a change of mind rather than a bad move; only shake at a genuine
|
||||
// dead end.
|
||||
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
|
||||
}
|
||||
|
||||
if (cardEl && cardEl.dataset.live === "1") {
|
||||
pick(pile, parseInt(cardEl.dataset.idx, 10));
|
||||
} else {
|
||||
drop();
|
||||
}
|
||||
});
|
||||
|
||||
// Double-click sends a card home. It's the idiom every solitaire has used for
|
||||
// thirty years, and the alternative is asking the player which foundation — a
|
||||
// question with exactly one right answer.
|
||||
root.addEventListener("dblclick", function (e) {
|
||||
if (busy || !board || board.phase !== "playing") return;
|
||||
var cardEl = e.target.closest('.pete-card[data-live="1"]');
|
||||
if (!cardEl) return;
|
||||
var pile = cardEl.dataset.pile;
|
||||
if (pile.charAt(0) === "f") return; // it's already home
|
||||
e.preventDefault();
|
||||
drop();
|
||||
send({ kind: "home", from: pile });
|
||||
});
|
||||
|
||||
stockEl.addEventListener("click", function () {
|
||||
if (busy || !board || board.phase !== "playing") return;
|
||||
if (stockEl.dataset.dead === "1") {
|
||||
say(gameMsg, "That was your last pass through the stock.", "bad");
|
||||
nope(stockEl);
|
||||
return;
|
||||
}
|
||||
drop();
|
||||
send({ kind: "draw" });
|
||||
});
|
||||
|
||||
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
||||
|
||||
cashBtn.addEventListener("click", function () {
|
||||
drop();
|
||||
send({ kind: "concede" });
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||
if (e.key === "Escape") { drop(); return; }
|
||||
if (busy || !board || board.phase !== "playing") return;
|
||||
|
||||
var k = e.key.toLowerCase();
|
||||
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
|
||||
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
|
||||
});
|
||||
|
||||
// ---- talking to the table --------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
cleared: "Cleared the board! 🎉",
|
||||
cashed: "Board cashed.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
||||
// this room, so it's the one that gets the confetti.
|
||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
||||
}
|
||||
|
||||
// play walks a server response onto the felt: the board is re-rendered, the
|
||||
// cards animate from where they were, and the chips follow.
|
||||
//
|
||||
// `money` — the chip bar catching up — is held back on a *settling* board until
|
||||
// the payout has physically swept home. A counter that pays you before the chips
|
||||
// arrive is a counter that has told you the ending.
|
||||
function play(view, money) {
|
||||
var v = view.solitaire;
|
||||
var events = view.sol_events || [];
|
||||
var settles = !!v && v.phase === "done";
|
||||
var recycled = events.some(function (e) { return e.kind === "recycle"; });
|
||||
|
||||
var pre = recycled ? recycleOut() : Promise.resolve();
|
||||
|
||||
return pre
|
||||
.then(function () {
|
||||
var before = snapshot();
|
||||
render(v);
|
||||
var plan = planOf(events);
|
||||
flashHome(events);
|
||||
return animate(before, plan);
|
||||
})
|
||||
.then(function () {
|
||||
if (!v) { money(); return; }
|
||||
return bank(v.stands).then(function () {
|
||||
if (!settles) { money(); return; }
|
||||
// The board is finished. Everything banked comes home, and only then
|
||||
// does the number in the bar move.
|
||||
verdict(v);
|
||||
return wait(300)
|
||||
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
|
||||
.then(money)
|
||||
.then(function () { controls(null); showBet(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(move) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say(gameMsg, "");
|
||||
return window.PeteGames.post("/api/games/solitaire/move", move)
|
||||
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
|
||||
.catch(function (err) {
|
||||
say(gameMsg, err.message, "bad");
|
||||
// Whatever this file thought the board was, the server is the authority on
|
||||
// it. Ask, and draw what it says.
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
// ---- buying a deck ---------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmountEl.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
tier = btn.dataset.tier;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b === btn ? "1" : "0";
|
||||
});
|
||||
showBet();
|
||||
});
|
||||
});
|
||||
|
||||
// The chip you click is the chip that flies. It lands on the spot in the rail,
|
||||
// which is where the price of the deck is stacked up before you pay it.
|
||||
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
||||
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say(tableMsg, "You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
|
||||
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
|
||||
|
||||
busy = true;
|
||||
say(tableMsg, "");
|
||||
var price = bet;
|
||||
|
||||
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
|
||||
.then(function (view) {
|
||||
// The deck is *bought*: the chips on the spot go across to the house and
|
||||
// do not come back. Then the spot starts again at nothing, and from here
|
||||
// on it holds what the board has earned back.
|
||||
return spot
|
||||
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
|
||||
.then(function () {
|
||||
bet = 0;
|
||||
showBet();
|
||||
window.PeteGames.apply(view);
|
||||
return dealOut(view.solitaire);
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(tableMsg, err.message, "bad");
|
||||
return window.PeteGames.refresh();
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
});
|
||||
|
||||
// dealOut lays the board and flies every card onto it out of the stock, one at a
|
||||
// time, across the columns — the way a deal actually goes down.
|
||||
function dealOut(v) {
|
||||
render(v);
|
||||
if (reduced || !v) return Promise.resolve();
|
||||
|
||||
var from = stockEl.getBoundingClientRect();
|
||||
var waits = [];
|
||||
|
||||
// The order a real deal goes in: one card to each column, then round again,
|
||||
// starting a column further along each time.
|
||||
var order = 0;
|
||||
for (var row = 0; row < 7; row++) {
|
||||
for (var col = row; col < 7; col++) {
|
||||
var colEl = tableauEl.children[col];
|
||||
var cardEl = colEl.children[row];
|
||||
if (!cardEl) continue;
|
||||
var now = cardEl.getBoundingClientRect();
|
||||
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
|
||||
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
|
||||
order++;
|
||||
}
|
||||
}
|
||||
return Promise.all(waits);
|
||||
}
|
||||
|
||||
// ---- coming in ------------------------------------------------------------
|
||||
|
||||
// The money bar owns the first fetch. The table picks up whatever it found —
|
||||
// including a board left mid-game by a reload or a redeploy, which comes back
|
||||
// exactly as it was, right down to what it has banked.
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.solitaire) {
|
||||
render(v.solitaire);
|
||||
spot.render(v.solitaire.stands);
|
||||
} else {
|
||||
controls(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
554
internal/web/static/js/trivia.js
Normal file
554
internal/web/static/js/trivia.js
Normal file
@@ -0,0 +1,554 @@
|
||||
// The trivia table.
|
||||
//
|
||||
// Same bargain as every other table in the room: the browser holds no game. It
|
||||
// sends an answer, and the server says how it went. The four buttons arrive
|
||||
// without any mark on which of them is right — that index is in the engine
|
||||
// state, on the server — and the reveal only comes back in the event that
|
||||
// decides the question, by which point knowing it is worth nothing.
|
||||
//
|
||||
// The countdown here is decoration, and it is important to be clear about that.
|
||||
// Nothing it does scores anything: the server timed the answer the moment it
|
||||
// arrived, against the clock it started when it served the question. Stopping
|
||||
// this bar, or reloading to restart it, changes nothing at all. What the bar
|
||||
// owes the player is *honesty* — so it is seeded from the seconds the server
|
||||
// says are left, not from when the browser got round to painting.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-trivia]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var questionEl = root.querySelector("[data-question]");
|
||||
var categoryEl = root.querySelector("[data-category]");
|
||||
var answersEl = root.querySelector("[data-answers]");
|
||||
var clockEl = root.querySelector("[data-clock]");
|
||||
var clockFillEl = root.querySelector("[data-clock-fill]");
|
||||
var countdownEl = root.querySelector("[data-countdown]");
|
||||
var ladderEl = root.querySelector("[data-ladder]");
|
||||
var multEl = root.querySelector("[data-multiple]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
var standsEl = root.querySelector("[data-stands]");
|
||||
var standsLbl = root.querySelector("[data-stands-label]");
|
||||
var rungEl = root.querySelector("[data-rung]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var walkBtn = root.querySelector("[data-walk]");
|
||||
var walkAmtEl = root.querySelector("[data-walk-amount]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// The bet spot, and the rule that comes with it: the number under the pile is
|
||||
// a readout of the pile, never the other way round.
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var bet = 0; // what you're building between games
|
||||
var busy = false;
|
||||
var game = null; // the round as the server last described it
|
||||
var tier = "medium";
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone, where) {
|
||||
var el = where || msgEl;
|
||||
if (!el) return;
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- the clock -------------------------------------------------------------
|
||||
|
||||
var raf = null;
|
||||
var clockDeadline = 0; // performance.now() ms at which this question dies
|
||||
var clockLimit = 1;
|
||||
var timedOut = false;
|
||||
|
||||
// HOT is when the bar stops being a progress meter and starts being a warning.
|
||||
var HOT = 5;
|
||||
|
||||
// GRACE is how long past its own zero the browser waits before reporting the
|
||||
// timeout. It cannot be negative: the browser's countdown starts when the
|
||||
// response *arrives*, so it is already behind the server's by the latency of
|
||||
// that response, and it therefore always reaches zero after the server has.
|
||||
// The grace is only there so that "always" survives a rounding error.
|
||||
var GRACE = 400;
|
||||
|
||||
function stopClock() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = null;
|
||||
}
|
||||
|
||||
function startClock(left, limit) {
|
||||
stopClock();
|
||||
timedOut = false;
|
||||
clockLimit = limit > 0 ? limit : 1;
|
||||
clockDeadline = performance.now() + left * 1000;
|
||||
tick();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
var left = (clockDeadline - performance.now()) / 1000;
|
||||
if (left < 0) left = 0;
|
||||
|
||||
// A transform, not a width: the browser can run this one without laying the
|
||||
// page out again on every frame.
|
||||
clockFillEl.style.transform = "scaleX(" + (left / clockLimit).toFixed(4) + ")";
|
||||
countdownEl.textContent = left.toFixed(1) + "s";
|
||||
clockEl.dataset.hot = left <= HOT && left > 0 ? "1" : "0";
|
||||
|
||||
if (left <= 0) {
|
||||
countdownEl.textContent = "0.0s";
|
||||
clockEl.dataset.hot = "0";
|
||||
timeUp();
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// timeUp reports the clock running out. It is not the browser *deciding* the
|
||||
// question — it is the browser telling the server the player never answered,
|
||||
// and the server (which has been holding the real clock all along) agreeing.
|
||||
function timeUp() {
|
||||
stopClock();
|
||||
if (timedOut || !game || game.phase !== "playing") return;
|
||||
// A move is already in flight. Come back rather than give up: this fires when
|
||||
// the server has rejected our last timeout report (its clock hadn't run out
|
||||
// yet) and the refresh has re-armed a countdown that is already at zero. Give
|
||||
// up here and the clock sits frozen at 0.0s and the question never resolves.
|
||||
if (busy) { setTimeout(timeUp, 250); return; }
|
||||
timedOut = true;
|
||||
lockAnswers();
|
||||
setTimeout(function () {
|
||||
// -1 is "no answer". The engine checks the clock before it checks the
|
||||
// choice, so this resolves as the timeout it is rather than a bad move.
|
||||
send("/api/games/trivia/answer", { choice: -1 }, gameMsgEl);
|
||||
}, GRACE);
|
||||
}
|
||||
|
||||
function clearClock() {
|
||||
stopClock();
|
||||
clockFillEl.style.transform = "scaleX(0)";
|
||||
countdownEl.textContent = "";
|
||||
clockEl.dataset.hot = "0";
|
||||
}
|
||||
|
||||
// ---- the question ----------------------------------------------------------
|
||||
|
||||
var KEYS = ["1", "2", "3", "4"];
|
||||
|
||||
function renderQuestion(v) {
|
||||
answersEl.innerHTML = "";
|
||||
if (!v || v.phase !== "playing" || !v.answers) {
|
||||
questionEl.textContent = "";
|
||||
categoryEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
categoryEl.textContent = v.category || "";
|
||||
questionEl.textContent = v.question || "";
|
||||
|
||||
v.answers.forEach(function (text, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "pete-answer";
|
||||
b.dataset.at = String(i);
|
||||
|
||||
var key = document.createElement("span");
|
||||
key.className = "pete-answer-key";
|
||||
key.textContent = KEYS[i] || "";
|
||||
key.setAttribute("aria-hidden", "true");
|
||||
|
||||
var label = document.createElement("span");
|
||||
label.textContent = text;
|
||||
|
||||
b.appendChild(key);
|
||||
b.appendChild(label);
|
||||
b.addEventListener("click", function () { answer(i); });
|
||||
answersEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function lockAnswers() {
|
||||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) { b.disabled = true; });
|
||||
}
|
||||
|
||||
// reveal marks the board once the server has decided. Nothing in here is known
|
||||
// until it comes back: the right answer arrives in the event, not in the view.
|
||||
function reveal(choice, correct) {
|
||||
answersEl.querySelectorAll(".pete-answer").forEach(function (b) {
|
||||
var i = parseInt(b.dataset.at, 10);
|
||||
b.disabled = true;
|
||||
if (i === choice && i === correct) b.dataset.state = "right";
|
||||
else if (i === choice) b.dataset.state = "wrong";
|
||||
else if (i === correct) b.dataset.state = "missed";
|
||||
else b.dataset.state = "dim";
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the meters ------------------------------------------------------------
|
||||
|
||||
function renderLadder(v) {
|
||||
ladderEl.innerHTML = "";
|
||||
var rungs = (v && v.rungs) || 12;
|
||||
var done = (v && v.rung) || 0;
|
||||
for (var i = 0; i < rungs; i++) {
|
||||
var pip = document.createElement("span");
|
||||
pip.className = "pete-rung";
|
||||
pip.dataset.on = i < done ? "1" : "0";
|
||||
ladderEl.appendChild(pip);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMeter(v) {
|
||||
if (!v) {
|
||||
multEl.textContent = "—";
|
||||
standsEl.textContent = "—";
|
||||
standsLbl.textContent = "if you walk";
|
||||
meterEl.dataset.cold = "1";
|
||||
rungEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
multEl.textContent = v.multiple.toFixed(2) + "×";
|
||||
standsEl.textContent = (v.stands || 0).toLocaleString();
|
||||
meterEl.dataset.cold = v.rung === 0 ? "1" : "0";
|
||||
|
||||
if (v.phase === "done") {
|
||||
standsLbl.textContent = v.net > 0 ? "banked" : "gone";
|
||||
rungEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
standsLbl.textContent = v.can_walk ? "if you walk" : "answer one to unlock";
|
||||
rungEl.textContent = "Question " + (v.rung + 1) + " of " + v.rungs;
|
||||
}
|
||||
|
||||
// knock rolls the multiple up to its new value rather than swapping it, so a
|
||||
// right answer reads as the total *growing* — which is the thing you're
|
||||
// deciding whether to risk.
|
||||
function climb(v) {
|
||||
var from = parseFloat(multEl.textContent) || 1;
|
||||
var to = v.multiple;
|
||||
if (reduced) { renderMeter(v); return; }
|
||||
var t0 = performance.now();
|
||||
meterEl.dataset.hit = "0";
|
||||
|
||||
(function step(now) {
|
||||
var p = Math.min(1, (now - t0) / 420);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else renderMeter(v);
|
||||
})(t0);
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
var back = payout - final.bet;
|
||||
|
||||
if (payout <= 0) {
|
||||
var chain = spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
return chain;
|
||||
}
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
// standing puts the stake back on the spot for the next ladder, the way every
|
||||
// other table in the room leaves your bet up.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
// pour grows the pile from whatever is on the spot, and settle has just swept
|
||||
// it clean — so it must not be told the chips are already there. Setting the
|
||||
// amount first counted the standing bet twice, and the spot printed 400 under
|
||||
// a 200 stake.
|
||||
return spot.pour(purseEl, amount);
|
||||
}
|
||||
|
||||
// ---- phases ----------------------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
walked: "Banked it.",
|
||||
cleared: "Cleared the board! 🎉",
|
||||
wrong: "Wrong.",
|
||||
timeout: "Out of time.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
|
||||
// Confetti only for clearing all twelve — the one thing in here worth it.
|
||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 });
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase === "playing";
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
if (walkBtn) {
|
||||
walkBtn.disabled = !live || !v.can_walk;
|
||||
walkAmtEl.textContent = (v && v.can_walk ? v.stands : 0).toLocaleString();
|
||||
}
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// paint puts a round up with no animation: the resume path, after a reload or a
|
||||
// redeploy. The clock picks up exactly where the server says it is — which is
|
||||
// the whole point of it being the server's clock.
|
||||
function paint(v) {
|
||||
if (!v) {
|
||||
clearClock();
|
||||
renderQuestion(null);
|
||||
renderLadder(null);
|
||||
renderMeter(null);
|
||||
spot.render(0);
|
||||
setPhase(null);
|
||||
return;
|
||||
}
|
||||
renderQuestion(v);
|
||||
renderLadder(v);
|
||||
renderMeter(v);
|
||||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||
setPhase(v);
|
||||
|
||||
if (v.phase === "playing") startClock(v.left, v.limit);
|
||||
else clearClock();
|
||||
}
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// play walks the server's events. Same rule as the other tables: on a live
|
||||
// round the money is already right (your stake left your pile when you pressed
|
||||
// Play, and it's on the spot), but on a settling one the chip bar is held back
|
||||
// until the chips have physically come home. A counter that pays you before
|
||||
// the reveal is a counter that has told you the ending.
|
||||
function play(view, money) {
|
||||
var events = view.triv_events || [];
|
||||
var final = view.trivia;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var chain = Promise.resolve();
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
stopClock();
|
||||
|
||||
events.forEach(function (e) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "ask":
|
||||
// A fresh question. Everything about the last one goes.
|
||||
verdictEl.classList.add("hidden");
|
||||
renderQuestion(final);
|
||||
renderLadder(final);
|
||||
if (final && final.phase === "playing") startClock(final.left, final.limit);
|
||||
return;
|
||||
|
||||
case "right":
|
||||
reveal(e.choice, e.correct);
|
||||
if (final) {
|
||||
// The rung lighting and the multiple climbing are one event,
|
||||
// because they are one event: this is what the answer was worth.
|
||||
climb({ multiple: e.multiple, rung: final.rung, rungs: final.rungs,
|
||||
stands: final.stands, can_walk: true, phase: "playing" });
|
||||
renderLadder(final);
|
||||
}
|
||||
return wait(900);
|
||||
|
||||
case "wrong":
|
||||
reveal(e.choice, e.correct);
|
||||
return wait(1100);
|
||||
|
||||
case "timeout":
|
||||
reveal(-1, e.correct);
|
||||
return wait(1100);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
if (!settles) {
|
||||
renderMeter(final);
|
||||
setPhase(final);
|
||||
return;
|
||||
}
|
||||
|
||||
// Over: the clock stops, the money moves, and only then does the bar catch up.
|
||||
clearClock();
|
||||
playing.classList.add("hidden");
|
||||
renderMeter(final);
|
||||
renderLadder(final);
|
||||
verdict(final);
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- talking to the table ---------------------------------------------------
|
||||
|
||||
function send(path, body, where) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("", null, where);
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad", where);
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && v.trivia) paint(v.trivia);
|
||||
else { paint(null); spot.render(0); }
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
}
|
||||
|
||||
function answer(i) {
|
||||
if (busy || timedOut || !game || game.phase !== "playing") return;
|
||||
stopClock();
|
||||
lockAnswers();
|
||||
send("/api/games/trivia/answer", { choice: i }, gameMsgEl);
|
||||
}
|
||||
|
||||
if (walkBtn) {
|
||||
walkBtn.addEventListener("click", function () {
|
||||
if (busy || !game || game.phase !== "playing" || !game.can_walk) return;
|
||||
stopClock();
|
||||
lockAnswers();
|
||||
send("/api/games/trivia/answer", { walk: true }, gameMsgEl);
|
||||
});
|
||||
}
|
||||
|
||||
// 1–4 answers the question. The key is printed on the button it answers.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
||||
if (!game || game.phase !== "playing" || busy) return;
|
||||
var i = KEYS.indexOf(e.key);
|
||||
if (i === -1) return;
|
||||
var btn = answersEl.querySelector('.pete-answer[data-at="' + i + '"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
e.preventDefault();
|
||||
answer(i);
|
||||
});
|
||||
|
||||
// ---- betting ----------------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
function pickTier(slug) {
|
||||
tier = slug;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||
});
|
||||
showBet();
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickTier(b.dataset.tier);
|
||||
});
|
||||
});
|
||||
|
||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||
// [data-chip] spans in the corner are the house's rack, and it is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (!tier) { say("Pick a difficulty first.", "bad"); return; }
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
// The stake stays on the spot for the whole ladder: it is what's at risk,
|
||||
// and it is riding on every question until you take it back or lose it.
|
||||
send("/api/games/trivia/start", { bet: bet, tier: tier });
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.trivia) {
|
||||
paint(v.trivia);
|
||||
if (v.trivia.phase === "done") verdict(v.trivia);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
707
internal/web/static/js/uno.js
Normal file
707
internal/web/static/js/uno.js
Normal file
@@ -0,0 +1,707 @@
|
||||
// The UNO 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 turn it handed
|
||||
// off to*, as a script of events. So a round trip here is a whole lap of the
|
||||
// table, and this file's main job is to play that lap back slowly enough that
|
||||
// you can see what happened to you.
|
||||
//
|
||||
// Two rules carried over from the tables that came before, both load-bearing:
|
||||
//
|
||||
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
|
||||
// the chip bar does not move until the chips that justify it have landed. So a
|
||||
// settling game holds the money back until the payout has physically swept home
|
||||
// — a counter that pays you before the last card goes down has told you the
|
||||
// ending.
|
||||
//
|
||||
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
|
||||
// backs you see up there is drawn from a number, because a number is all that
|
||||
// crossed the wire.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-uno]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var seatsEl = root.querySelector("[data-seats]");
|
||||
var handEl = root.querySelector("[data-hand]");
|
||||
var deckEl = root.querySelector("[data-deck]");
|
||||
var deckCntEl = root.querySelector("[data-deck-count]");
|
||||
var discardEl = root.querySelector("[data-discard]");
|
||||
var colourEl = root.querySelector("[data-colour]");
|
||||
var turnEl = root.querySelector("[data-turn-label]");
|
||||
var countEl = root.querySelector("[data-count-label]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
var paysEl = root.querySelector("[data-pays]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
var tableEl = root.querySelector("[data-table-name]");
|
||||
|
||||
var wildEl = root.querySelector("[data-wild]");
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var drawBtn = root.querySelector("[data-draw]");
|
||||
var passBtn = root.querySelector("[data-pass]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var bet = 0; // what you're building between games
|
||||
var busy = false;
|
||||
var game = null; // the game as the server last described it
|
||||
var tier = "table";
|
||||
var pendingWild = -1; // the wild you clicked, waiting on a colour
|
||||
var played = -1; // the card you just played, so the script lifts that one out
|
||||
// of the hand and not merely the first one that lit up
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone, where) {
|
||||
var el = where || msgEl;
|
||||
if (!el) return;
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- drawing the cards -----------------------------------------------------
|
||||
|
||||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
||||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
||||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
||||
|
||||
// card builds one UNO card. The oval across the middle at an angle is the whole
|
||||
// look of the thing — without it a card reads as a coloured button.
|
||||
function card(c, opts) {
|
||||
opts = opts || {};
|
||||
var el = document.createElement(opts.button ? "button" : "div");
|
||||
if (opts.button) el.type = "button";
|
||||
el.className = "pete-uno-card";
|
||||
el.dataset.c = c.wild ? "wild" : c.color;
|
||||
|
||||
var face = document.createElement("span");
|
||||
face.className = "pete-uno-face";
|
||||
|
||||
var oval = document.createElement("span");
|
||||
oval.className = "pete-uno-oval";
|
||||
oval.textContent = GLYPHS[c.value] || c.value;
|
||||
face.appendChild(oval);
|
||||
|
||||
// The corners, as printed.
|
||||
["tl", "br"].forEach(function (at) {
|
||||
var corner = document.createElement("span");
|
||||
corner.className = "pete-uno-corner";
|
||||
corner.dataset.at = at;
|
||||
corner.textContent = GLYPHS[c.value] || c.value;
|
||||
corner.setAttribute("aria-hidden", "true");
|
||||
face.appendChild(corner);
|
||||
});
|
||||
|
||||
// A wild is four quadrants of colour — and once it has been played as a
|
||||
// colour, that colour is the one it wears on the pile.
|
||||
if (c.wild) {
|
||||
var wheel = document.createElement("span");
|
||||
wheel.className = "pete-uno-wheel";
|
||||
face.appendChild(wheel);
|
||||
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
||||
}
|
||||
|
||||
el.appendChild(face);
|
||||
el.setAttribute("aria-label", label(c));
|
||||
return el;
|
||||
}
|
||||
|
||||
function label(c) {
|
||||
var v = c.value === "+2" ? "draw two" :
|
||||
c.value === "+4" ? "wild draw four" :
|
||||
c.value === "wild" ? "wild" : c.value;
|
||||
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||||
return c.color + " " + v;
|
||||
}
|
||||
|
||||
function back() {
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-uno-card pete-uno-card-back";
|
||||
var b = document.createElement("span");
|
||||
b.className = "pete-uno-back";
|
||||
el.appendChild(b);
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- the board -------------------------------------------------------------
|
||||
|
||||
// FAN is the most backs a bot's hand draws, however many it holds. Past this
|
||||
// the fan is unreadable and the number beside it is doing the work anyway.
|
||||
var FAN = 8;
|
||||
|
||||
function renderSeats(v) {
|
||||
seatsEl.innerHTML = "";
|
||||
if (!v) return;
|
||||
v.seats.forEach(function (s, i) {
|
||||
if (s.you) return; // you are the hand at the bottom, not a seat up here
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-uno-seat";
|
||||
el.dataset.seat = String(i);
|
||||
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
||||
if (s.uno) el.dataset.uno = "1";
|
||||
|
||||
var fan = document.createElement("div");
|
||||
fan.className = "pete-uno-fan";
|
||||
var show = Math.min(s.cards, FAN);
|
||||
for (var n = 0; n < show; n++) {
|
||||
var b = back();
|
||||
b.style.setProperty("--i", n);
|
||||
b.style.setProperty("--n", show);
|
||||
fan.appendChild(b);
|
||||
}
|
||||
el.appendChild(fan);
|
||||
|
||||
var name = document.createElement("p");
|
||||
name.className = "pete-uno-name";
|
||||
name.textContent = s.name;
|
||||
el.appendChild(name);
|
||||
|
||||
var count = document.createElement("p");
|
||||
count.className = "pete-uno-count";
|
||||
count.dataset.count = "";
|
||||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
el.appendChild(count);
|
||||
|
||||
seatsEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
|
||||
|
||||
// Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
|
||||
// fan; the deck is the deck.
|
||||
function seatAnchor(i) {
|
||||
if (i === 0) return handEl;
|
||||
var el = seatEl(i);
|
||||
return el ? el.querySelector(".pete-uno-fan") : deckEl;
|
||||
}
|
||||
|
||||
function renderHand(v) {
|
||||
handEl.innerHTML = "";
|
||||
if (!v || !v.hand) return;
|
||||
var playable = {};
|
||||
(v.playable || []).forEach(function (i) { playable[i] = true; });
|
||||
var yours = v.turn === 0 && v.phase !== "done";
|
||||
|
||||
v.hand.forEach(function (c, i) {
|
||||
var el = card(c, { button: true });
|
||||
el.style.setProperty("--i", i);
|
||||
el.dataset.at = String(i);
|
||||
var ok = yours && playable[i];
|
||||
el.dataset.on = ok ? "1" : "0";
|
||||
el.disabled = !ok || busy;
|
||||
if (ok) el.addEventListener("click", function () { pick(i, c); });
|
||||
handEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPiles(v) {
|
||||
discardEl.innerHTML = "";
|
||||
if (!v) {
|
||||
deckCntEl.textContent = "0";
|
||||
colourEl.textContent = "";
|
||||
root.querySelector(".pete-uno").dataset.c = "";
|
||||
return;
|
||||
}
|
||||
deckCntEl.textContent = String(v.deck);
|
||||
var top = card(v.top);
|
||||
top.classList.add("pete-uno-top");
|
||||
discardEl.appendChild(top);
|
||||
|
||||
// The colour in play, which after a wild is not the colour of the card you
|
||||
// are looking at. So it is said in words, and the felt takes a tint of it —
|
||||
// this is the single most missable fact on the table.
|
||||
colourEl.textContent = v.color;
|
||||
colourEl.dataset.c = v.color;
|
||||
feltEl.dataset.c = v.color;
|
||||
}
|
||||
|
||||
var feltEl = root.querySelector(".pete-uno");
|
||||
|
||||
function renderRail(v) {
|
||||
if (!v) {
|
||||
paysEl.textContent = "—";
|
||||
meterEl.dataset.cold = "1";
|
||||
tableEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
paysEl.textContent = (v.pays || 0).toLocaleString();
|
||||
meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
|
||||
tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
|
||||
}
|
||||
|
||||
function renderTurn(v) {
|
||||
if (!v || v.phase === "done") {
|
||||
turnEl.textContent = "";
|
||||
countEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
var yours = v.turn === 0;
|
||||
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
||||
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
||||
turnEl.textContent = who;
|
||||
turnEl.dataset.you = yours ? "1" : "0";
|
||||
var n = v.hand.length;
|
||||
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
|
||||
}
|
||||
|
||||
// ---- phases ----------------------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
won: "You went out! 🎉",
|
||||
lost: "Beaten to it.",
|
||||
stuck: "Nobody could move.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
||||
text = v.seats[v.winner].name + " went out first.";
|
||||
}
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
|
||||
}
|
||||
|
||||
// controls is the one place that decides what you can click: whose turn the
|
||||
// server says it is, and whether a move of yours is still in flight. Both
|
||||
// halves matter, and they used to be spread across three functions that each
|
||||
// knew half of it — which is how the table came to unlock itself in the middle
|
||||
// of a bot's turn.
|
||||
function controls() {
|
||||
var live = !!game && game.phase !== "done";
|
||||
var yours = live && game.turn === 0;
|
||||
var drawn = live && game.phase === "drawn";
|
||||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||||
});
|
||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
||||
if (passBtn) passBtn.disabled = busy || !yours;
|
||||
if (deckEl) deckEl.disabled = busy || !yours || drawn;
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase !== "done";
|
||||
var drawn = live && v.phase === "drawn";
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
hideWild();
|
||||
|
||||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
||||
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||||
controls();
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// paint puts the board up with no animation: the resume path, after a reload or
|
||||
// a redeploy. Whatever the server says is on the table is on the table.
|
||||
function paint(v) {
|
||||
renderSeats(v);
|
||||
renderPiles(v);
|
||||
renderHand(v);
|
||||
renderRail(v);
|
||||
renderTurn(v);
|
||||
spot.render(v && v.phase !== "done" ? v.bet : 0);
|
||||
setPhase(v);
|
||||
}
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// throwCard sends a card from one place to another across the felt.
|
||||
function throwCard(node, from, to, opts) {
|
||||
opts = opts || {};
|
||||
return FX.flyNode(node, from, to, {
|
||||
duration: opts.duration || 380,
|
||||
lift: opts.lift == null ? 0.7 : opts.lift,
|
||||
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
|
||||
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
|
||||
delay: opts.delay || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// bump keeps a bot's fan honest during the script: the server's count is the
|
||||
// truth, but between events the fan has to grow and shrink as cards move, or
|
||||
// the flight lands on a pile that hasn't changed.
|
||||
function bump(seat, left) {
|
||||
if (seat === 0 || left == null) return;
|
||||
var el = seatEl(seat);
|
||||
if (!el) return;
|
||||
var fan = el.querySelector(".pete-uno-fan");
|
||||
var count = el.querySelector("[data-count]");
|
||||
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
|
||||
if (!fan) return;
|
||||
var show = Math.min(left, FAN);
|
||||
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
||||
while (fan.children.length < show) fan.appendChild(back());
|
||||
Array.prototype.forEach.call(fan.children, function (b, i) {
|
||||
b.style.setProperty("--i", i);
|
||||
b.style.setProperty("--n", fan.children.length);
|
||||
});
|
||||
el.dataset.uno = left === 1 ? "1" : "0";
|
||||
}
|
||||
|
||||
function spotlight(seat) {
|
||||
seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
|
||||
el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
|
||||
});
|
||||
}
|
||||
|
||||
// badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
|
||||
// these things; the badge is what makes them land on somebody.
|
||||
function badge(seat, text, tone) {
|
||||
var host = seat === 0 ? handEl : seatEl(seat);
|
||||
if (!host || reduced) return Promise.resolve();
|
||||
var b = document.createElement("span");
|
||||
b.className = "pete-uno-badge";
|
||||
b.dataset.tone = tone || "";
|
||||
b.textContent = text;
|
||||
host.appendChild(b);
|
||||
return new Promise(function (r) {
|
||||
setTimeout(function () { b.remove(); r(); }, 900);
|
||||
});
|
||||
}
|
||||
|
||||
// play walks the server's script. On a live game the money is already right —
|
||||
// your stake left your pile when you pressed Deal, and it's on the spot — so
|
||||
// the chip bar can catch up immediately. On a settling one it waits.
|
||||
function play(view, money) {
|
||||
var events = view.uno_events || [];
|
||||
var final = view.uno;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var chain = Promise.resolve();
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
events.forEach(function (e, n) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "deal":
|
||||
// The deal isn't animated card by card: seven cards to each of four
|
||||
// seats is 28 flights and a player who wants to play. The hand fans in
|
||||
// on its own (CSS), which reads as being dealt without taking as long.
|
||||
paint(final);
|
||||
return wait(320);
|
||||
|
||||
case "play": {
|
||||
spotlight(e.seat);
|
||||
var node = card(e.card);
|
||||
var from = seatAnchor(e.seat);
|
||||
// Your own card leaves the hand it was in, so the hand has to lose it
|
||||
// before the flight or the card is briefly in two places.
|
||||
if (e.seat === 0 && played >= 0) {
|
||||
var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
|
||||
if (live) live.style.visibility = "hidden";
|
||||
played = -1;
|
||||
}
|
||||
bump(e.seat, e.left);
|
||||
return throwCard(node, from, discardEl, { index: n })
|
||||
.then(function () {
|
||||
discardEl.innerHTML = "";
|
||||
var top = card(e.card);
|
||||
top.classList.add("pete-uno-top", "pete-uno-land");
|
||||
discardEl.appendChild(top);
|
||||
if (e.color) {
|
||||
colourEl.textContent = e.color;
|
||||
colourEl.dataset.c = e.color;
|
||||
feltEl.dataset.c = e.color;
|
||||
}
|
||||
return wait(e.seat === 0 ? 120 : 300);
|
||||
});
|
||||
}
|
||||
|
||||
case "draw":
|
||||
case "forced": {
|
||||
spotlight(e.seat);
|
||||
var to = seatAnchor(e.seat);
|
||||
var backs = [];
|
||||
for (var i = 0; i < Math.min(e.n, 4); i++) {
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||
}
|
||||
var punished = e.kind === "forced";
|
||||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
||||
return Promise.all(backs).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
// Your own drawn card comes face up — it's yours, and the server
|
||||
// sent its face for exactly this.
|
||||
if (e.seat === 0 && e.card) return wait(160);
|
||||
return wait(punished ? 380 : 180);
|
||||
});
|
||||
}
|
||||
|
||||
case "skip":
|
||||
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
||||
|
||||
case "reverse":
|
||||
feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
|
||||
return badge(e.seat, "Reverse").then(function () { return wait(60); });
|
||||
|
||||
case "uno":
|
||||
return badge(e.seat, "UNO!", "uno");
|
||||
|
||||
case "reshuffle":
|
||||
// The discard has just gone back under, so the counter has to as
|
||||
// well: the draws that follow in this same script count down from it,
|
||||
// and counting down from a deck that still reads empty gets nowhere.
|
||||
deckCntEl.textContent = String(e.n);
|
||||
deckEl.classList.add("pete-uno-shuffle");
|
||||
return wait(420).then(function () {
|
||||
deckEl.classList.remove("pete-uno-shuffle");
|
||||
});
|
||||
|
||||
case "pass":
|
||||
spotlight(e.seat);
|
||||
return wait(140);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
if (!settles) {
|
||||
paint(final);
|
||||
return;
|
||||
}
|
||||
|
||||
// Over: the board settles, the money moves, and only then does the bar
|
||||
// catch up.
|
||||
renderSeats(final);
|
||||
renderPiles(final);
|
||||
renderHand(final);
|
||||
renderTurn(final);
|
||||
renderRail(final);
|
||||
verdict(final);
|
||||
playing.classList.add("hidden");
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
|
||||
// Not `back`: that is the card back up there, and a number wearing its name
|
||||
// is a landmine for whoever next wants a face-down card in here.
|
||||
var profit = payout - final.bet;
|
||||
return spot
|
||||
.pour(houseEl, profit, { gap: 60 })
|
||||
.then(function () { return wait(profit > 0 ? 380 : 200); })
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
// standing puts the stake back on the spot for the next game, the way every
|
||||
// other table in the room leaves your bet up. pour grows the pile from what it
|
||||
// is told is already there, so the amount is never set first — that bug printed
|
||||
// double the stake under trivia's chips for a day.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
return spot.pour(purseEl, amount);
|
||||
}
|
||||
|
||||
// ---- moves ------------------------------------------------------------------
|
||||
|
||||
function pick(i, c) {
|
||||
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
|
||||
if (c.wild) { askColour(i); return; }
|
||||
move({ kind: "play", index: i });
|
||||
}
|
||||
|
||||
function askColour(i) {
|
||||
pendingWild = i;
|
||||
wildEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideWild() {
|
||||
pendingWild = -1;
|
||||
if (wildEl) wildEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// COLOURS maps the colour a player names to the number the engine calls it.
|
||||
// Wild is 0 there on purpose — a move with no colour on it must not quietly
|
||||
// mean red — so these start at 1 and this table is the only place that knows.
|
||||
var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
|
||||
|
||||
root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy || pendingWild < 0) return;
|
||||
var i = pendingWild;
|
||||
var c = COLOURS[b.dataset.colourPick];
|
||||
hideWild();
|
||||
move({ kind: "play", index: i, color: c });
|
||||
});
|
||||
});
|
||||
|
||||
var cancelWild = root.querySelector("[data-colour-cancel]");
|
||||
if (cancelWild) cancelWild.addEventListener("click", hideWild);
|
||||
|
||||
function move(body) {
|
||||
played = body.kind === "play" ? body.index : -1;
|
||||
send("/api/games/uno/move", body, gameMsgEl);
|
||||
}
|
||||
|
||||
if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
|
||||
if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
|
||||
if (passBtn) {
|
||||
passBtn.addEventListener("click", function () {
|
||||
if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return;
|
||||
move({ kind: "pass" });
|
||||
});
|
||||
}
|
||||
|
||||
function drawOne() {
|
||||
if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
|
||||
move({ kind: "draw" });
|
||||
}
|
||||
|
||||
// ---- talking to the table ----------------------------------------------------
|
||||
|
||||
// send takes the table away for the whole move — not just the request, but the
|
||||
// script that comes back with it. A lap of this table is seconds of animation,
|
||||
// and for every one of them the game on screen is a game the server has already
|
||||
// moved past. Clicking a card in there would send a move against a board that
|
||||
// no longer exists. So busy comes off at the end of the script, not the end of
|
||||
// the request, which is what the other tables do too.
|
||||
function send(path, body, where) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("", null, where);
|
||||
controls();
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
say(err.message, "bad", where);
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && v.uno) paint(v.uno);
|
||||
else { paint(null); spot.render(0); }
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
busy = false;
|
||||
controls();
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- betting ------------------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
function pickTier(slug) {
|
||||
tier = slug;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||
});
|
||||
showBet();
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickTier(b.dataset.tier);
|
||||
});
|
||||
});
|
||||
|
||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||
// [data-chip] spans in the rack are the house's, and the house is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
// The stake sits on the spot for the whole game: it is what's riding on you
|
||||
// going out first.
|
||||
send("/api/games/uno/start", { bet: bet, tier: tier });
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.uno) {
|
||||
paint(v.uno);
|
||||
if (v.uno.phase === "done") verdict(v.uno);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStatusTemplateExecutes(t *testing.T) {
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
45
internal/web/templates/_chipbar.html
Normal file
45
internal/web/templates/_chipbar.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{{define "_chipbar"}}
|
||||
<section data-chipbar
|
||||
class="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-x-6 gap-y-4">
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Chips in front of you</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span data-chips class="font-display text-3xl font-bold tabular-nums">—</span>
|
||||
<span data-pending class="hidden text-sm font-semibold text-[color:var(--ink)]/60 animate-pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your wallet</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span data-euros class="font-display text-2xl font-bold tabular-nums text-[color:var(--ink)]/70">—</span>
|
||||
<span class="text-sm text-[color:var(--ink)]/40" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 p-1.5">
|
||||
<label for="buyin-amount" class="sr-only">How many chips?</label>
|
||||
<input id="buyin-amount" data-buyin-amount type="number" min="1" step="1" value="100"
|
||||
inputmode="numeric" enterkeyhint="go"
|
||||
class="w-24 rounded-full bg-[color:var(--card)] px-3 py-1.5 text-sm font-semibold tabular-nums
|
||||
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
|
||||
<button type="button" data-buyin
|
||||
class="rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Buy chips
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" data-cashout
|
||||
class="rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-bold shadow-pete border-2 border-[color:var(--ink)]/10
|
||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Cash out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||
</section>
|
||||
{{end}}
|
||||
133
internal/web/templates/blackjack.html
Normal file
133
internal/web/templates/blackjack.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{{define "title"}}Blackjack · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-blackjack>
|
||||
|
||||
<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">Blackjack</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Six decks · pays 3:2 · dealer hits soft 17</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The two things on it that aren't cards are the shoe the cards
|
||||
come out of and the rack the money comes out of — a chip on this table is
|
||||
always travelling between one of those and the spot in front of you. -->
|
||||
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="pete-rack" data-at="shoe" 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>
|
||||
|
||||
<!-- The shoe every card flies out of. -->
|
||||
<div class="pete-shoe" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative space-y-8">
|
||||
|
||||
<!-- Dealer -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span data-dealer-label class="text-xs font-bold uppercase tracking-wider text-white/60">Dealer</span>
|
||||
<span data-dealer-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
|
||||
</div>
|
||||
<div data-dealer class="pete-hand" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<!-- What just happened. -->
|
||||
<div class="flex min-h-[2.75rem] items-center justify-center">
|
||||
<!-- The pill is white, so its text is ink — not var(--ink), which is the
|
||||
room's cream and would be white on white. -->
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
|
||||
<!-- Player: the bet sits in front of the cards it's riding on. -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-white/60">You</span>
|
||||
<span data-player-total class="hidden rounded-full bg-black/25 px-2.5 py-0.5 text-xs font-bold tabular-nums text-white"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
<div data-player class="pete-hand flex-1" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Betting: shown between hands. -->
|
||||
<section data-betting class="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-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-deal
|
||||
class="ml-auto 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">
|
||||
Deal
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Playing: shown while a hand is live. -->
|
||||
<section data-actions 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 justify-center gap-3">
|
||||
<button type="button" data-move="hit"
|
||||
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">
|
||||
Hit
|
||||
</button>
|
||||
<button type="button" data-move="stand"
|
||||
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
|
||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Stand
|
||||
</button>
|
||||
<button type="button" data-move="double"
|
||||
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
|
||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Double
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double
|
||||
</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/blackjack.js" defer></script>
|
||||
{{end}}
|
||||
@@ -13,6 +13,81 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .ShowRoster}}
|
||||
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||||
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
|
||||
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||
{{range .Roster}}
|
||||
<li class="flex items-center gap-4 px-5 py-3">
|
||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||
<span class="font-semibold">{{.Name}}</span>
|
||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||
</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// The board is state, not a story: an open tab should keep telling the truth
|
||||
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
|
||||
(function () {
|
||||
var list = document.getElementById('roster-list');
|
||||
var status = document.getElementById('roster-status');
|
||||
var card = document.getElementById('roster-card');
|
||||
if (!list) return;
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function row(a) {
|
||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||
esc(a.Where) + idle + '</span></li>';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var rows = data.adventurers || [];
|
||||
list.innerHTML = rows.length
|
||||
? rows.map(row).join('')
|
||||
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
|
||||
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
|
||||
card.classList.toggle('opacity-60', !!data.stale);
|
||||
})
|
||||
.catch(function () { /* transient — the next tick will do */ });
|
||||
}
|
||||
|
||||
setInterval(refresh, 60000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
|
||||
155
internal/web/templates/games.html
Normal file
155
internal/web/templates/games.html
Normal file
@@ -0,0 +1,155 @@
|
||||
{{define "title"}}{{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-8">
|
||||
|
||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
|
||||
<p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
|
||||
Real euros, from the same wallet as everything else. Chips are one euro each,
|
||||
and whatever you don't spend goes straight back when you cash out.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden shrink-0 sm:flex items-end gap-1.5" aria-hidden="true">
|
||||
<span class="cs-stack" data-chip="5"></span>
|
||||
<span class="cs-stack" data-chip="100" style="--stack:3"></span>
|
||||
<span class="cs-stack" data-chip="25" style="--stack:2"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<section>
|
||||
<h2 class="font-display text-2xl font-bold mb-4">The tables</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
<a href="/games/blackjack"
|
||||
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">Blackjack</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Six decks, blackjack pays 3:2.</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">
|
||||
The dealer stands on 17 and hits a soft one. House takes {{.RakePct}}% of what you win,
|
||||
and nothing at all when you lose or push.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/hangman"
|
||||
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">Hangman</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Guess the phrase, keep the multiple.</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">
|
||||
Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess
|
||||
takes a tenth off what a win is worth.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/solitaire"
|
||||
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">Solitaire</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Buy the deck, win it back a card at a time.</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">
|
||||
Vegas rules. Your stake buys the deck and doesn't come back — every card you
|
||||
get home pays a slice of it in. Cash the board whenever you like.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/trivia"
|
||||
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">Trivia</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Climb the ladder, or take the money.</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">
|
||||
{{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong
|
||||
one loses the lot. Answer fast: the multiple decays as the clock runs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/uno"
|
||||
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">UNO</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Go out first, take the table.</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">
|
||||
One to three bots, and the more of them there are the more it pays: up to 3.6× for
|
||||
beating a full table. Anybody else going out first takes your stake.
|
||||
</p>
|
||||
</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}}
|
||||
<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">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl grayscale">{{.Emoji}}</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display text-xl font-bold text-[color:var(--ink)]/50">{{.Name}}</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/40">{{.Blurb}}</p>
|
||||
</div>
|
||||
<span class="ml-auto shrink-0 rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-[color:var(--ink)]/40">Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<h2 class="font-display text-xl font-bold mb-2">House rules</h2>
|
||||
<ul class="space-y-1.5 text-sm text-[color:var(--ink)]/70">
|
||||
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
|
||||
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
|
||||
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
|
||||
<li>· Walk away for half an hour and the house cashes you out for you, so your euros never sit in limbo.</li>
|
||||
<li>· Every hand is logged with the seed it was dealt from, so any hand can be dealt again exactly as it fell.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}<script src="/static/js/games.js" defer></script>{{end}}
|
||||
91
internal/web/templates/games_layout.html
Normal file
91
internal/web/templates/games_layout.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en" data-room="{{.Room.Slug}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{.Room.Name}}{{end}}</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/output.css">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
|
||||
<meta name="theme-color" content="#17231d">
|
||||
<script>
|
||||
// Which room you're standing in, from your clock rather than the server's:
|
||||
// Casinopolis in daylight, Casino Night Zone once the lights come on at six.
|
||||
// Same rule as roomAt() in games_pages.go — keep the two in step.
|
||||
//
|
||||
// The server already painted its best guess into the markup, so this only
|
||||
// corrects a player whose evening isn't the server's.
|
||||
(function () {
|
||||
var ROOMS = {
|
||||
"casinopolis": "Casinopolis",
|
||||
"casino-night": "Casino Night Zone",
|
||||
};
|
||||
function roomAt(h) { return (h >= 6 && h < 18) ? "casinopolis" : "casino-night"; }
|
||||
function apply() {
|
||||
var slug = roomAt(new Date().getHours());
|
||||
// The palette can be set from <head> — the sign can't: this script runs
|
||||
// before the header exists. So set the attribute now (no flash of the
|
||||
// wrong room) and always re-stamp the name, which is a no-op until the
|
||||
// header is there to stamp. Bailing out early when the slug already
|
||||
// matches would leave the server's name under the browser's palette.
|
||||
document.documentElement.dataset.room = slug;
|
||||
document.querySelectorAll("[data-room-name]").forEach(function (el) {
|
||||
el.textContent = ROOMS[slug];
|
||||
});
|
||||
}
|
||||
apply();
|
||||
document.addEventListener("DOMContentLoaded", apply);
|
||||
setInterval(apply, 60000);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
|
||||
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
|
||||
|
||||
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<a href="/games" class="group flex min-w-0 items-center gap-3">
|
||||
{{template "_comb" .}}
|
||||
<span class="min-w-0">
|
||||
<span data-room-name class="block font-display text-2xl sm:text-3xl font-bold leading-none tracking-tight">{{.Room.Name}}</span>
|
||||
<span class="mt-1 block text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--ink)]/45">Chips are euros</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{{if .User}}
|
||||
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
|
||||
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-5xl px-4 pb-20">
|
||||
{{block "main" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
|
||||
Play for what you can lose. Cash out whenever you like.
|
||||
</footer>
|
||||
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* The house mark: a honeycomb cell struck like a chip. Stands in for a logo,
|
||||
and deliberately isn't a face — nobody is watching you play. */}}
|
||||
{{define "_comb"}}
|
||||
<span class="cs-comb grid h-11 w-11 shrink-0 place-items-center transition-transform group-hover:-rotate-6" aria-hidden="true">
|
||||
<svg viewBox="0 0 32 32" class="h-11 w-11">
|
||||
<polygon points="16,2.5 27.5,9.25 27.5,22.75 16,29.5 4.5,22.75 4.5,9.25"
|
||||
fill="var(--accent)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<polygon points="16,9 21.5,12.25 21.5,18.75 16,22 10.5,18.75 10.5,12.25"
|
||||
fill="none" stroke="var(--ink)" stroke-width="1.75" stroke-linejoin="round" opacity="0.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
{{end}}
|
||||
176
internal/web/templates/hangman.html
Normal file
176
internal/web/templates/hangman.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{{define "title"}}Hangman · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-hangman>
|
||||
|
||||
<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">Hangman</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The gallows is on it because the gallows is the meter: it counts
|
||||
down your lives and your winnings at the same time. -->
|
||||
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="pete-rack" 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>
|
||||
|
||||
<!-- The gallows and the stake stack up on the left; the phrase and what it's
|
||||
worth take the rest. The right column keeps clear of the rack in the
|
||||
corner, which is why nothing in it is pushed to the far edge. -->
|
||||
<div class="relative grid gap-x-10 gap-y-8 lg:grid-cols-[auto,1fr] lg:items-start">
|
||||
|
||||
<div class="flex flex-col items-center gap-6 lg:items-start">
|
||||
<svg data-gallows viewBox="0 0 130 150" class="pete-gallows h-48 w-auto sm:h-56" role="img"
|
||||
aria-labelledby="gallows-title">
|
||||
<title id="gallows-title">The gallows</title>
|
||||
<g class="pete-gallows-frame">
|
||||
<path d="M8 145 H72" />
|
||||
<path d="M28 145 V8" />
|
||||
<path d="M28 8 H92" />
|
||||
<path d="M92 8 V26" />
|
||||
</g>
|
||||
<g class="pete-gallows-body">
|
||||
<circle data-part="0" cx="92" cy="38" r="12" />
|
||||
<path data-part="1" d="M92 50 V88" />
|
||||
<path data-part="2" d="M92 58 L74 76" />
|
||||
<path data-part="3" d="M92 58 L110 76" />
|
||||
<path data-part="4" d="M92 88 L76 114" />
|
||||
<path data-part="5" d="M92 88 L108 114" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- The stake sits under the gallows it's riding on, the same spot the
|
||||
blackjack table puts your bet on. -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
<p data-lives class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
|
||||
<!-- What a win is worth right now. It falls as the figure fills in.
|
||||
This row is the only one level with the house rack in the corner, so
|
||||
it is the only one that has to keep clear of it — the board below
|
||||
gets the full width, and needs it. -->
|
||||
<div class="flex flex-wrap items-center gap-3 pr-24 sm:pr-28">
|
||||
<div class="pete-meter" data-meter>
|
||||
<span class="pete-meter-label">Pays</span>
|
||||
<span data-multiple class="pete-meter-value">—</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">
|
||||
<span data-stands class="font-bold tabular-nums text-white/90">—</span>
|
||||
<span data-stands-label>if you get it</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The phrase. -->
|
||||
<div data-board class="pete-board" aria-live="polite" aria-label="The phrase"></div>
|
||||
|
||||
<!-- Letters that missed. -->
|
||||
<div class="flex min-h-[1.75rem] flex-wrap items-center gap-1.5">
|
||||
<span data-wrong-label class="hidden text-xs font-bold uppercase tracking-wider text-white/40">Missed</span>
|
||||
<div data-wrong class="flex flex-wrap gap-1.5"></div>
|
||||
</div>
|
||||
|
||||
<!-- What just happened. -->
|
||||
<div class="flex min-h-[2.75rem] items-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Guessing: shown while a game is live. -->
|
||||
<section data-guessing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<div data-keyboard class="pete-keys"></div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<label for="solve" class="sr-only">Guess the whole phrase</label>
|
||||
<input id="solve" data-solve-input type="text" autocomplete="off" enterkeyhint="go"
|
||||
placeholder="Or just say what it is…"
|
||||
class="min-w-0 flex-1 rounded-full bg-[color:var(--ink)]/5 px-4 py-2.5 text-sm font-semibold
|
||||
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
|
||||
<button type="button" data-solve
|
||||
class="rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Solve
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
Type a letter to guess it. A wrong solve costs a life, same as a wrong letter.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<!-- Betting: shown between games. -->
|
||||
<section data-betting 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">How long a phrase?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Tiers}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
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)]">{{printf "%.1f" .Base}}×</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto 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">
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
<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/games.js" defer></script>
|
||||
<script src="/static/js/hangman.js" defer></script>
|
||||
{{end}}
|
||||
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}}
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
|
||||
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
|
||||
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
@@ -320,5 +322,6 @@
|
||||
<script src="/static/js/settings.js" defer></script>
|
||||
<script src="/static/js/reader.js" defer></script>
|
||||
<script src="/static/js/pwa.js" defer></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
168
internal/web/templates/solitaire.html
Normal file
168
internal/web/templates/solitaire.html
Normal file
@@ -0,0 +1,168 @@
|
||||
{{define "title"}}Solitaire · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-solitaire>
|
||||
|
||||
<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">Solitaire</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">You buy the deck. Every card you get home buys some of it back.</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The board takes the room; the money lives in a rail down the
|
||||
right of it, where the house rack can't collide with the foundations. -->
|
||||
<section class="pete-felt pete-solitaire 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-6 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
||||
|
||||
<!-- The board. -->
|
||||
<div class="min-w-0 space-y-4 sm:space-y-6">
|
||||
|
||||
<!-- The stock and the waste on the left, the foundations on the right,
|
||||
exactly where a real layout puts them. -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<button type="button" data-stock class="pete-slot pete-stock" aria-label="Turn over the stock">
|
||||
<span data-stock-count class="pete-slot-count">0</span>
|
||||
<span data-stock-recycle class="pete-slot-recycle hidden" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><polyline points="21 3 21 8 16 8"></polyline>
|
||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path><polyline points="3 21 3 16 8 16"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div data-waste class="pete-waste" aria-label="The waste"></div>
|
||||
</div>
|
||||
|
||||
<div data-foundations class="flex items-start gap-1.5 sm:gap-2" aria-label="The foundations"></div>
|
||||
</div>
|
||||
|
||||
<!-- The seven columns. -->
|
||||
<div data-tableau class="pete-tableau" aria-label="The tableau"></div>
|
||||
|
||||
<!-- What just happened. -->
|
||||
<div class="flex min-h-[2.75rem] items-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The rail: the house's rack, what you've banked, and the meter that
|
||||
reads it. Everything the money does happens between these two. -->
|
||||
<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="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Banked</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
|
||||
<div class="pete-meter w-full justify-center" data-meter>
|
||||
<span class="pete-meter-label">Home</span>
|
||||
<span data-home class="pete-meter-value">0<span class="text-white/40">/{{.FullDeck}}</span></span>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs leading-relaxed text-white/55">
|
||||
<span data-per-card class="font-bold text-white/85">—</span> a card<br>
|
||||
<span data-break-even></span>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playing: shown while a board is live. -->
|
||||
<section data-playing 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-auto
|
||||
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
|
||||
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Send home ⤴
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-[color:var(--ink)]/45">
|
||||
Click a card, then where it goes. Double-click sends it home.
|
||||
</p>
|
||||
|
||||
<button type="button" data-cash
|
||||
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Cash the board · <span data-cash-amount class="tabular-nums">0</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>
|
||||
|
||||
<!-- Buying a deck: shown between games. -->
|
||||
<section data-betting 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">Which deal?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Deals}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
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)]">{{printf "%.1f" .Base}}×</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">
|
||||
Square with the house at {{.BreakEven}} cards home.
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">The deck costs</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Put {{.}} more down"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto 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">
|
||||
Buy the deck
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-[color:var(--ink)]/40">
|
||||
The stake buys the deck outright, so it doesn't come back. What comes back is
|
||||
whatever you get home, a fifty-second of the multiple at a time. Stop whenever
|
||||
you like and keep it. There's no undo.
|
||||
</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/solitaire.js" defer></script>
|
||||
{{end}}
|
||||
27
internal/web/templates/story.html
Normal file
27
internal/web/templates/story.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto">
|
||||
<nav class="mb-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> All dispatches
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
|
||||
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
|
||||
Reporting from the realm, this is Pete.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
149
internal/web/templates/trivia.html
Normal file
149
internal/web/templates/trivia.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{{define "title"}}Trivia · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-trivia>
|
||||
|
||||
<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">Trivia</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">{{.Rungs}} questions · answer fast, or don't bother</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The clock is the biggest thing on it, because the clock is the
|
||||
game: a right answer is worth what it's worth *when you give it*. -->
|
||||
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="pete-rack" 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>
|
||||
|
||||
<!-- The meter and the ladder. This row is the only one level with the house
|
||||
rack in the corner, so it is the only one that has to keep clear of it. -->
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 pr-32 sm:pr-28">
|
||||
<div class="pete-meter" data-meter>
|
||||
<span class="pete-meter-label">Worth</span>
|
||||
<span data-multiple class="pete-meter-value">—</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">
|
||||
<span data-stands class="font-bold tabular-nums text-white/90">—</span>
|
||||
<span data-stands-label>if you walk</span>
|
||||
</p>
|
||||
<div class="pete-ladder ml-auto" data-ladder aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- The question. -->
|
||||
<div class="mt-7 min-h-[16rem]" data-round>
|
||||
|
||||
<div class="pete-clock" data-clock>
|
||||
<div class="pete-clock-fill" data-clock-fill></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p data-category class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||
<p data-countdown class="font-display text-lg font-bold tabular-nums text-white/70"></p>
|
||||
</div>
|
||||
|
||||
<h2 data-question class="mt-1 font-display text-xl font-bold leading-snug text-white sm:text-2xl" aria-live="polite"></h2>
|
||||
|
||||
<div data-answers class="mt-5 grid gap-2.5 sm:grid-cols-2"></div>
|
||||
|
||||
<div class="mt-5 flex min-h-[2.75rem] items-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The stake, on the same spot every other table puts it. -->
|
||||
<div class="mt-2 flex items-center gap-4">
|
||||
<div class="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
<p data-rung class="text-xs font-bold uppercase tracking-wider text-white/50"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playing: shown while a ladder is live. -->
|
||||
<section data-playing 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">
|
||||
<p class="text-sm text-[color:var(--ink)]/50">
|
||||
Press <span class="font-bold">1</span>–<span class="font-bold">4</span>, or click one. A wrong answer, or the clock, loses the lot.
|
||||
</p>
|
||||
<button type="button" data-walk
|
||||
class="ml-auto 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">
|
||||
Take the money · <span data-walk-amount>0</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>
|
||||
|
||||
<!-- Betting: shown between games. -->
|
||||
<section data-betting 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">How hard?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Quizzes}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
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)]">{{printf "%.2f" .Fast}}×</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">
|
||||
{{.Limit}}s a question · slowest answer still pays {{printf "%.2f" .Buzzer}}×
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto 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">
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
The first question is the price of sitting down: you can only walk once you've answered one.
|
||||
</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/games.js" defer></script>
|
||||
<script src="/static/js/trivia.js" defer></script>
|
||||
{{end}}
|
||||
178
internal/web/templates/uno.html
Normal file
178
internal/web/templates/uno.html
Normal file
@@ -0,0 +1,178 @@
|
||||
{{define "title"}}UNO · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-uno>
|
||||
|
||||
<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">UNO</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The board takes the room and the money lives in a rail beside it:
|
||||
there is no corner free on this table for the house rack to sit in. -->
|
||||
<section class="pete-felt pete-uno 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 space-y-5">
|
||||
|
||||
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
|
||||
all you are ever told about them, and all a real opponent shows you. -->
|
||||
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
|
||||
|
||||
<!-- The middle: what you draw from, and what you play onto. -->
|
||||
<div class="flex items-center justify-center gap-5 sm:gap-8">
|
||||
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
|
||||
<span class="pete-uno-back" aria-hidden="true"></span>
|
||||
<span data-deck-count class="pete-uno-deck-count">0</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
||||
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your hand. -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<p data-turn-label class="text-xs font-bold uppercase tracking-wider text-white/50" aria-live="polite"></p>
|
||||
<p data-count-label class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||
</div>
|
||||
<div data-hand class="pete-uno-hand" aria-label="Your hand"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[2.75rem] items-center justify-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The rail. -->
|
||||
<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="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="pete-meter" data-meter>
|
||||
<span class="pete-meter-label">Pays</span>
|
||||
<span data-pays 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>
|
||||
|
||||
<!-- Naming a colour for a wild. It sits over the felt because until it is
|
||||
answered there is no legal move on the table underneath it. -->
|
||||
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
|
||||
<div class="pete-uno-wild-box">
|
||||
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2.5">
|
||||
<button type="button" data-colour-pick="red" class="pete-uno-swatch" data-c="red">Red</button>
|
||||
<button type="button" data-colour-pick="blue" class="pete-uno-swatch" data-c="blue">Blue</button>
|
||||
<button type="button" data-colour-pick="yellow" class="pete-uno-swatch" data-c="yellow">Yellow</button>
|
||||
<button type="button" data-colour-pick="green" class="pete-uno-swatch" data-c="green">Green</button>
|
||||
</div>
|
||||
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playing: shown while a game is live. -->
|
||||
<section data-playing 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">
|
||||
<p class="text-sm text-[color:var(--ink)]/50">
|
||||
Click a card that lights up. Nothing lights up? Draw one.
|
||||
</p>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button type="button" data-draw
|
||||
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">
|
||||
Draw
|
||||
</button>
|
||||
<button type="button" data-pass
|
||||
class="hidden 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">
|
||||
Keep it
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Betting: shown between games. -->
|
||||
<section data-betting 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">Who are you playing?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Tables}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
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)]">{{printf "%.1f" .Base}}×</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">
|
||||
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto 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">
|
||||
Deal
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
||||
</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/games.js" defer></script>
|
||||
<script src="/static/js/uno.js" defer></script>
|
||||
{{end}}
|
||||
@@ -67,6 +67,14 @@ func thumbURL(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
// Root-relative sources are our own local assets (e.g. the adventure
|
||||
// emblems) — serve them directly; the thumbnailer only handles remote
|
||||
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
|
||||
// it's a protocol-relative remote image, and it has to keep going through
|
||||
// the guarded thumbnailer like any other third-party URL.
|
||||
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
|
||||
return src
|
||||
}
|
||||
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIndexTrendingAndBadges(t *testing.T) {
|
||||
storage.RecordStoryView(id)
|
||||
storage.RecordStoryView(id)
|
||||
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
|
||||
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
126
internal/web/trivia_bank.go
Normal file
126
internal/web/trivia_bank.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/opentdb"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// Keeping the trivia bank stocked.
|
||||
//
|
||||
// The bank is not consumed by play — a question drawn is still there afterwards
|
||||
// — so this loop is about *variety*, not supply. It fills each difficulty up to
|
||||
// a target and then has nothing to do, which is why a pass that finds the bank
|
||||
// full costs three COUNT queries and no network at all.
|
||||
|
||||
// bankTarget is how many questions of each difficulty we want to hold. Twelve
|
||||
// rungs drawn from four hundred is enough that a regular player doesn't start
|
||||
// recognising them, and it's a size OpenTDB's pool can actually fill.
|
||||
const bankTarget = 400
|
||||
|
||||
// bankMaxFetches bounds one pass. At OpenTDB's politeness interval this is a
|
||||
// couple of minutes of drip, after which the loop goes back to sleep rather than
|
||||
// hammering a free API for an hour to top up the last few questions.
|
||||
const bankMaxFetches = 12
|
||||
|
||||
// bankInterval is how often we go back and look. The bank is a slow-moving
|
||||
// thing: it only grows, and it only needs to grow once.
|
||||
const bankInterval = 12 * time.Hour
|
||||
|
||||
// StartTriviaBank launches the refill loop if the casino is on. Safe to call
|
||||
// unconditionally; a no-op when games are off.
|
||||
func (s *Server) StartTriviaBank(ctx context.Context) {
|
||||
if !s.gamesReady() {
|
||||
return
|
||||
}
|
||||
go s.runTriviaBank(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) runTriviaBank(ctx context.Context) {
|
||||
slog.Info("games: trivia bank refill started", "target", bankTarget, "interval", bankInterval)
|
||||
|
||||
s.refillTriviaBank(ctx)
|
||||
|
||||
ticker := time.NewTicker(bankInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.refillTriviaBank(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refillTriviaBank tops each difficulty up toward the target, politely.
|
||||
//
|
||||
// Every failure here is survivable and none of them stop the loop: OpenTDB is a
|
||||
// free API that is sometimes down, and a thin bank costs a player nothing worse
|
||||
// than a "give it a minute" when they try to start a ladder.
|
||||
func (s *Server) refillTriviaBank(ctx context.Context) {
|
||||
client := opentdb.New()
|
||||
fetches := 0
|
||||
|
||||
for _, t := range trivia.Tiers {
|
||||
for fetches < bankMaxFetches {
|
||||
have, err := storage.CountTrivia(t.Difficulty)
|
||||
if err != nil {
|
||||
slog.Error("games: trivia bank count", "difficulty", t.Difficulty, "err", err)
|
||||
break
|
||||
}
|
||||
if have >= bankTarget {
|
||||
break
|
||||
}
|
||||
|
||||
qs, err := client.Fetch(ctx, t.Difficulty, opentdb.Batch)
|
||||
fetches++
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("games: trivia bank fetch", "difficulty", t.Difficulty, "err", err)
|
||||
// Whatever went wrong, waiting is the only sensible response: the
|
||||
// likeliest cause is the rate limit, and retrying at once earns another.
|
||||
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
added, err := storage.AddTriviaQuestions(t.Difficulty, qs)
|
||||
if err != nil {
|
||||
slog.Error("games: trivia bank store", "difficulty", t.Difficulty, "err", err)
|
||||
break
|
||||
}
|
||||
slog.Info("games: trivia bank filled",
|
||||
"difficulty", t.Difficulty, "fetched", len(qs), "new", added, "have", have+added)
|
||||
|
||||
// The API hands back random batches, so once the bank is deep the
|
||||
// overlap gets heavy and a batch adds almost nothing new. When it adds
|
||||
// nothing at all, this difficulty has given us what it has: stop asking.
|
||||
if added == 0 {
|
||||
break
|
||||
}
|
||||
if !sleepCtx(ctx, opentdb.Politeness) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sleepCtx waits, unless we're being shut down. Reports false if we are.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
80
internal/web/zz_drive_test.go
Normal file
80
internal/web/zz_drive_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestDrive is not a test. It is a hand crank: it stands the casino up on a real
|
||||
// port with one funded player so a browser can be pointed at it. Skipped unless
|
||||
// PETE_DRIVE is set. Delete it if it ever stops earning its keep.
|
||||
func TestDrive(t *testing.T) {
|
||||
if os.Getenv("PETE_DRIVE") == "" {
|
||||
t.Skip("set PETE_DRIVE=1 to hand-drive the casino")
|
||||
}
|
||||
// Built through New() with the tables already open, because the /games routes
|
||||
// are registered at construction — newCasino() switches them on afterwards,
|
||||
// which is fine for handler tests and useless for a browser.
|
||||
storage.Close()
|
||||
if err := storage.Init(filepath.Join(t.TempDir(), "drive.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer storage.Close()
|
||||
|
||||
s, err := New(config.WebConfig{
|
||||
SiteTitle: "Pete",
|
||||
ListenAddr: ":0",
|
||||
BaseURL: "http://127.0.0.1",
|
||||
Games: config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"},
|
||||
}, nil, true, config.AdventureConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// New() only mounts the casino when auth is live, and auth means an OIDC
|
||||
// discovery call. Sign the cookie by hand and mount the routes here instead —
|
||||
// the handshake is not what a browser is here to look at.
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
fund(t, 5000)
|
||||
|
||||
static, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
mux.HandleFunc("GET /games", s.handleLobby)
|
||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
|
||||
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
|
||||
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: "sub-1", Username: "reala", Exp: time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
fmt.Printf("DRIVE_URL=%s\nDRIVE_COOKIE=%s=%s\n", srv.URL, sessionCookie, s.auth.sign(payload))
|
||||
|
||||
// Long enough for a browser to buy in, deal a few hands, and be screenshotted.
|
||||
deadline := time.Now().Add(3 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if _, err := http.Get(srv.URL + "/healthz"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
41
main.go
41
main.go
@@ -248,6 +248,13 @@ func main() {
|
||||
if postingEnabled && roundRobinMode {
|
||||
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
|
||||
for name := range cfg.Matrix.Channels {
|
||||
// The adventure channel drives its own posting: priority beats go
|
||||
// live at ingest, bulletins wait for the daily digest. Leaving it in
|
||||
// the rotation would let the scheduler post bulletins one-by-one
|
||||
// (they're "classified, not yet posted") and steal them from the digest.
|
||||
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
|
||||
continue
|
||||
}
|
||||
channelNames = append(channelNames, name)
|
||||
}
|
||||
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
|
||||
@@ -258,14 +265,41 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter: the web ingest handler posts priority adventure beats live to
|
||||
// Matrix through the same queue everything else uses (bypasses pacing).
|
||||
//
|
||||
// Adventure carries its own enable switch and is push-based: facts arrive
|
||||
// from gogobee at ingest, they are not polled, classified or paced, and they
|
||||
// never enter the round-robin rotation. So it is gated on [adventure] alone,
|
||||
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
|
||||
// an operator running "news on the web only, adventure live in the games
|
||||
// room" is a legitimate configuration. A nil poster (adventure off, or no
|
||||
// channel) still keeps both the live beats and the digest loop shut.
|
||||
var advPost web.PriorityPoster
|
||||
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
|
||||
advPost = func(p web.AdvPost) {
|
||||
queue.PostNow(poster.QueueItem{
|
||||
GUID: p.GUID,
|
||||
Headline: p.Headline,
|
||||
Lede: p.Lede,
|
||||
ImageURL: p.ImageURL,
|
||||
ArticleURL: p.ArticleURL,
|
||||
Source: p.Source,
|
||||
Channel: p.Channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Start the read-only web UI alongside the Matrix bot.
|
||||
if cfg.Web.Enabled {
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
|
||||
if err != nil {
|
||||
slog.Error("web server init failed", "err", err)
|
||||
} else {
|
||||
go ws.Start(ctx)
|
||||
ws.StartPushSender(ctx)
|
||||
ws.StartAdventureDigest(ctx)
|
||||
ws.StartTriviaBank(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +404,9 @@ func runLocal(cfg *config.Config) {
|
||||
poller.Start(ctx)
|
||||
slog.Info("local: pollers started")
|
||||
|
||||
// Local mode never connects to Matrix, so it's always web-only.
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false)
|
||||
// Local mode never connects to Matrix, so it's always web-only: adventure
|
||||
// stories still ingest and render, but nothing posts (nil priority poster).
|
||||
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
|
||||
if err != nil {
|
||||
slog.Error("local: web server init failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
920
pete_games_plan.md
Normal file
920
pete_games_plan.md
Normal file
@@ -0,0 +1,920 @@
|
||||
# Pete Games — games.parodia.dev
|
||||
|
||||
A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros.
|
||||
Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia.
|
||||
|
||||
Companion to `gogobee_mischief_plan.md`, which already established the web↔game seam.
|
||||
This plan reuses that seam wholesale and does not invent a second one.
|
||||
|
||||
---
|
||||
|
||||
## 0. Progress — last updated 2026-07-14
|
||||
|
||||
A multi-session build. This section is the handover; read it before anything else.
|
||||
|
||||
### Decisions taken (these close §9's open questions)
|
||||
|
||||
- **Chips are 1:1 with euros.** No second denomination.
|
||||
- **Session buy-in cap: €10,000**, enforced against chips held *plus* buy-ins still
|
||||
in flight, so it can't be cleared by firing several requests at once.
|
||||
- **A house rake**, 5% in blackjack's `DefaultRules`, taken from *winnings only* —
|
||||
never the stake. A push returns the bet untouched; a loss is never charged a fee.
|
||||
- **The site shares Pete's design, not Pete's shell.** *(Revised 2026-07-13 — this
|
||||
replaces the earlier "the site must look like Pete", which meant `layout.html`
|
||||
itself.)* The casino is its own place. It takes the design language — Fredoka/
|
||||
Nunito, the four palette vars, `rounded-3xl`, `shadow-pete`, the bubbly weight of
|
||||
everything — and takes none of the furniture: no Pete avatar, no channel nav, no
|
||||
search, no reader, no settings, no weather canvas, no PWA. It has its own layout
|
||||
(`games_layout.html`), its own header, its own footer, its own scripts. Still not
|
||||
an SPA; still server-rendered `html/template`.
|
||||
- **It has two names, on a clock.** Casinopolis by day, Casino Night Zone from six
|
||||
in the evening — palette, felt and the sign over the door all change together.
|
||||
This is the news app's phase system pointed at a joke: one `data-room` attribute,
|
||||
two palette blocks, and a rule shared between `roomAt()` in Go (first paint) and
|
||||
the same rule in JS (the player's own clock, so a player abroad gets their own
|
||||
evening).
|
||||
- **Dealing is animated.** Cards visibly dealt and flipped, chips that move. This is
|
||||
a requirement, not polish to drop when the clock runs out.
|
||||
|
||||
### Done
|
||||
|
||||
- **Phase 0 — euro idempotency (gogobee).** `euro_transactions.external_id` + a
|
||||
partial unique index, and `CreditIdem`/`DebitIdem` in `internal/plugin/euro.go`:
|
||||
balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay
|
||||
reports success without moving money again; a rejection writes nothing, so the same
|
||||
GUID stays retryable once the player is good for it. Six tests, including eight
|
||||
goroutines racing one GUID. *(gogobee `ab2bcf0`)*
|
||||
- **`pete/internal/games/cards`** — the shared deck gogobee never had. RNG is
|
||||
threaded, never the package global, so a hand is reproducible from its seed.
|
||||
*(pete `8310b30`)*
|
||||
- **`pete/internal/games/blackjack`** — pure reducer,
|
||||
`ApplyMove(state, move) (state, []Event, error)`, where an error means the move was
|
||||
illegal and nothing else. State is a plain value: it serializes, so a hand survives
|
||||
a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake.
|
||||
*(pete `8310b30`)*
|
||||
- **`pete/internal/storage/games.go`** — the euro/chip border. `game_chips`,
|
||||
`game_escrow`, `game_hands`. Chips appear only once gogobee confirms it took the
|
||||
euros; chips are destroyed the moment a cash-out opens (so they can't be bet while
|
||||
their euros are in flight) and come back if the credit fails. Table cap, 30-minute
|
||||
reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)*
|
||||
|
||||
- **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`,
|
||||
`POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest
|
||||
token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row,
|
||||
calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict
|
||||
back through `pete_emit_queue` — which grew a `path` column so escrow verdicts
|
||||
ride the same durable queue as adventure facts rather than getting a second one.
|
||||
`peteclient.Flush` sends the verdict immediately instead of waiting out the 15s
|
||||
sender tick, because a player is watching a spinner. A row re-offered after a
|
||||
gogobee crash replays as a no-op: 13 tests across both repos, including a fake
|
||||
Pete that offers the same row three times and a player who is charged once.
|
||||
|
||||
- **Identity.** `preferred_username` now rides in the signed session, and
|
||||
`SessionUser.MatrixUser(server)` maps it to `@user:parodia.dev`. The session cookie
|
||||
takes an opt-in `web.auth.cookie_domain`, so a sign-in on news is a sign-in on games;
|
||||
the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is
|
||||
derived per-request so a login that starts on games comes back to games. A Host we
|
||||
don't own is never echoed into a redirect. *(pete `cb84e1d`)*
|
||||
- **Blackjack, playable end to end.** `game_live_hands` (the hand in progress,
|
||||
engine state and all, so a redeploy mid-hand is survivable), the session-authed play
|
||||
surface (`internal/web/games_play.go`), the lobby and table pages, and the dealing
|
||||
animation. Driven in a real browser: chips staked before the deal, hole card withheld
|
||||
from the payload until the reveal, payout settled back into the stack.
|
||||
|
||||
- **The casino moved out.** Its own layout (`games_layout.html`), parsed as its own
|
||||
template set alongside the news one; `gamesPage` no longer embeds the news
|
||||
`pageData`, which is what stops the old furniture drifting back one convenient
|
||||
field at a time. Two rooms on a clock (above), the felt reupholstered from the
|
||||
room's vars, and a house mark that is a honeycomb chip rather than a face.
|
||||
- **The cards are cards.** Corner indices in both corners (the bottom one upside
|
||||
down, as printed), pips laid out on the three-by-seven grid a real deck uses,
|
||||
bottom-half pips inverted, courts as a letter with the suit over each shoulder,
|
||||
and a screen-reader label that says "Queen of hearts" instead of "Q♥".
|
||||
|
||||
- **The money moves.** The felt grew the two things it was missing: a **bet spot**
|
||||
in front of you and the **house's rack** beside the shoe, so every chip on the
|
||||
table is always travelling between one of those and the other. A bet is *built*
|
||||
by throwing chips onto the spot (the chip you clicked is the chip that flies);
|
||||
the stake sits there through the hand; the house pays out of its rack into the
|
||||
spot; the whole pile is then swept back to your pile. A loss goes to the rack and
|
||||
doesn't come back. `casino-fx.js` is the shared engine — `fly`/`flyMany` (WAAPI,
|
||||
on an arc, out of a fixed overlay so nothing clips them), `chipsFor` (an amount
|
||||
broken into the fewest chips, capped at what's worth watching), `burst`, `count`.
|
||||
|
||||
Two rules hold it together, and both are load-bearing:
|
||||
|
||||
1. **The number under the pile is a readout of the pile**, never the other way
|
||||
round. So the bet starts at zero rather than at a default nobody put down, and
|
||||
a settled hand puts your stake *back on the spot* as a standing bet — otherwise
|
||||
the panel prints "your bet: 300" over an empty circle.
|
||||
2. **The chip bar does not move until the chips that justify it have landed.** On
|
||||
a live hand the money applies immediately (your stake left your pile and is
|
||||
visibly on the spot); on a settling hand `play()` holds the apply until the
|
||||
payout has swept home. A counter that pays you before the dealer turns over is
|
||||
a counter that has told you the ending.
|
||||
|
||||
Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or
|
||||
two of resting tilt each), the dealer takes a beat before drawing out, and a
|
||||
natural gets confetti — the only thing in the room that does.
|
||||
|
||||
- **A way to actually look at it.** `internal/web/devcasino_test.go` is the casino on
|
||||
a port with one signed-in, funded player: `PETE_DEV_CASINO=:7788 go test
|
||||
./internal/web -run TestDevCasino -timeout 0`. Skipped without the env var. It
|
||||
wires its own routes because `New()` decides whether the casino exists at the
|
||||
moment it builds the mux, and the test rig signs the player in afterwards. **Drive
|
||||
the table in a real browser before believing anything about it** — this pass found
|
||||
a white-on-white verdict pill, a rack that collided with the dealer, and Hit still
|
||||
being offered over a table that was being paid out, none of which a Go test can see.
|
||||
|
||||
- **Deployed, 2026-07-14.** https://games.parodia.dev is live. What that took, since
|
||||
the shape of it was not quite what this plan guessed:
|
||||
- The edge is **Traefik**, not Caddy (`/mash/traefik/config/provider.yml`, root-owned,
|
||||
file provider so it hot-reloads). The casino needed no router of its own — the
|
||||
existing `pete` router's rule grew a second host:
|
||||
``Host(`news.parodia.dev`) || Host(`games.parodia.dev`)``, and ACME issued the cert
|
||||
on its own. DNS for the games host already pointed at the box.
|
||||
- Authentik lives at **auth.parodia.dev**, and the app's OAuth2 provider is "Pete News"
|
||||
(pk 12). It now holds both callbacks, strict: news and games. The provider's
|
||||
`redirect_uris` is a list of objects, not strings.
|
||||
- Server config gained `[web.games]` (enabled, host, `matrix_server = "parodia.dev"`)
|
||||
and `web.auth.cookie_domain = ".parodia.dev"`, which is what makes a news sign-in a
|
||||
games sign-in. Old host-only session cookies don't carry over — a signed-in user
|
||||
signs in once more, and after that the session spans both.
|
||||
- **gogobee is not on that box.** It runs on the LAN at `reala@192.168.1.212`, in a
|
||||
screen session, out of `~/gogobee`, and it has no key for its own GitHub remote —
|
||||
deploy it with `ssh -A` so the pull rides your agent. Its escrow loop needs no new
|
||||
config: it is gated on the `FEATURE_PETE_NEWS` / `PETE_INGEST_*` env in `~/.env`
|
||||
that adventure news already set. Restarted, it logs
|
||||
`pete games: escrow loop started interval=3s`.
|
||||
|
||||
- **Hangman, and it plays for chips.** *(2026-07-14. This revises §7's "Phase 2 —
|
||||
no escrow": the decision was that a free game in a casino reads as a demo, so
|
||||
hangman stakes chips like everything else and reuses the money path whole.)*
|
||||
- **The gallows is the payout meter.** You pick a tier, stake, and get six
|
||||
lives. Every wrong guess draws a limb *and* takes a tenth off the base
|
||||
multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×,
|
||||
long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so
|
||||
a win never hands back less than the stake, and the rake still comes out of
|
||||
winnings only.
|
||||
- `internal/games/hangman` — the same pure reducer as blackjack, phrases
|
||||
embedded (`phrases.txt`, 205 of them, video-game flavoured, lifted from
|
||||
gogobee). `State.Pays()` is the number the felt quotes *and* the number
|
||||
settle() lands on: they were briefly two sums and the table advertised a
|
||||
pre-rake payout it didn't honour. One function now, and a test that walks a
|
||||
game asserting the quote equals the payout at every step.
|
||||
- **The browser never sees the phrase.** Cells carry the letter or an empty
|
||||
string — not the letter with a hidden flag — and the phrase itself is only
|
||||
added to the payload once the game is over and it decides nothing.
|
||||
- Two things the storage layer already gave us for free, and one it didn't:
|
||||
`game_live_hands` is keyed on the *player*, so "one game at a time" holds
|
||||
across games with no new code (a live hangman 409s a blackjack deal). But
|
||||
`table()` used to unmarshal any live row as a blackjack hand — which does not
|
||||
*fail* on a hangman row, it just silently yields an empty hand. It now
|
||||
dispatches on `live.Game`.
|
||||
- `commit()` in games_play.go is the shared settle path (seat → pay → audit →
|
||||
clear → touch). Both games go through it so neither re-derives an ordering
|
||||
that took a while to get right. `casinoRoutes()` is likewise the single route
|
||||
list, because devcasino_test.go has to wire its own mux and a second copy is
|
||||
a copy that stops including the newest game.
|
||||
- Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the
|
||||
bar landed on it; six wrong took the stake and nothing more; a reload
|
||||
mid-phrase brought back the board, the limbs, the multiple, the spent keys and
|
||||
the stake on the spot. Two layout bugs only the browser could show: the lives
|
||||
counter ran under the house rack, and the board wrapped a word early because
|
||||
the rack's clearance padding was on the whole column instead of the one row
|
||||
level with it.
|
||||
|
||||
- **Solitaire, and it plays for chips.** *(2026-07-14, jumping the queue ahead of
|
||||
trivia because the user asked for it.)*
|
||||
- **Vegas scoring**, which is the only way solitaire has ever actually been a
|
||||
gambling game. You do not win or lose the deal — you **buy the deck** for your
|
||||
stake, and every card you get home to a foundation pays a fifty-second of the
|
||||
tier's multiple back. Cash the board whenever you like and keep what you've
|
||||
banked; a board that has gone dead is therefore a decision, not a wall. There
|
||||
is no undo, because the stake is spent the moment the deck is bought and an
|
||||
undo would be a way to walk a losing board backwards until it wins.
|
||||
- Three deals, and the two dials are the whole difficulty of Klondike: **Patient**
|
||||
(draw 1, unlimited passes, 1.4×, square at 38 cards), **Vegas** (draw 3, three
|
||||
passes, 2.2×, square at 24), **Cutthroat** (draw 3, one pass, 3.4×, square at
|
||||
16). `Tier.BreakEven()` is what the felt quotes, because "2.2×" tells a player
|
||||
nothing about a game where the multiple is paid a card at a time.
|
||||
- `internal/games/klondike` — the same pure reducer. `Pays()` is one function for
|
||||
the same reason hangman's is. Two fuzzers hold the deck together: no card is
|
||||
ever lost or duplicated by any sequence of moves, and the board stays
|
||||
well-formed (every face-up run is a run, no column has cards face-down under
|
||||
nothing). The first thing a test caught was a **recycle that reversed the
|
||||
waste** — it flips as a block, so the card drawn first comes out first, and
|
||||
reversing would have dealt a different game on every pass and broken the seed.
|
||||
- **The browser never sees the stock or a face-down card.** Bigger than
|
||||
blackjack's hole card: that's most of the deck. Columns send a face-down
|
||||
*count*, never the cards. The events, unlike blackjack's, need no filtering —
|
||||
every card they carry is one the move just turned face up.
|
||||
- **The table re-renders and animates the difference (FLIP).** Blackjack plays
|
||||
back a script because a hand only grows at one end; solitaire moves runs from
|
||||
anywhere to anywhere and an auto-finish moves eleven cards at once. So
|
||||
`solitaire.js` measures where every card is, re-renders the board the server
|
||||
sent, and plays each card from its old place to its new one. The board on
|
||||
screen is therefore always exactly the board the server says exists. The events
|
||||
supply only what a diff can't: where a *newly revealed* card came from (the
|
||||
stock, or a flip in place) and what the board is now worth.
|
||||
- **The rules are mirrored in JS**, deliberately, and only to light up the columns
|
||||
a held card can go to. The server still decides every move; a disagreement
|
||||
snaps the board back to whatever it says. Being shown where a card goes is the
|
||||
game teaching you; being told no after you commit is the game scolding you.
|
||||
- Two things got extracted rather than copied, which is the rule this room runs
|
||||
on: **`casino-cards.js`** (the deck — faces, pips, the flip; was inside
|
||||
blackjack.js) and **`PeteFX.spot()`** (the pile of chips and the number under
|
||||
it, which owns the "the number is a readout of the pile" rule so no table can
|
||||
break it). Blackjack now uses both.
|
||||
- **Driven in a browser, 2026-07-14, and it holds up.** Every worry on the list
|
||||
came back clean. A Patient deck bought for 200 dealt a correct Klondike (28 cards
|
||||
across the seven columns, 24 left in the stock), quoted `+5.4 a card` and `38 more
|
||||
to break even` — which is the tier's arithmetic, not a guess — and the money
|
||||
conserved end to end: 5,000 → 4,800 to buy the deck → one card home banked 5 →
|
||||
4,805 cashed out. The FLIP does not jump on a re-render. The seven columns fit at
|
||||
390px with no horizontal overflow (`docScrollW == clientW`), the rail stacks under
|
||||
the board rather than colliding with it, and the console is silent.
|
||||
- **And blackjack survived the rewire**, which was the real thing to check. Five
|
||||
hands, and the felt agreed with `/api/games/table` on every one. The rake still
|
||||
comes out of winnings only: a 400 win paid back 780 (the stake, plus 400 less 5%),
|
||||
and a push returned all 600 with nothing taken.
|
||||
- One thing to know before you go looking for a bug that isn't there: the bare
|
||||
`<span data-chip>` elements are the *house rack's decoration*. Only
|
||||
`button[data-chip]` carries a listener. A driver script that clicks `[data-chip]`
|
||||
hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's
|
||||
action buttons are also `[data-move="stand"]`, not `[data-stand]`.
|
||||
|
||||
- **Trivia, and it plays for chips.** *(2026-07-14. Built, and now **played** — see
|
||||
"Driven in a browser" at the bottom of this entry, which is where the two bugs
|
||||
were.)*
|
||||
- **A ladder.** Stake once, then answer a run of twelve. Every right answer
|
||||
multiplies what you're holding, a wrong one loses the lot, and you may walk
|
||||
with what you've built. Clearing all twelve ends the run and banks it — a
|
||||
ladder with no top is a slot machine you can't stop playing, and eventually
|
||||
every player loses everything to one bad question.
|
||||
- **The clock is the game, and it is the anti-google mechanism.** Trivia answers
|
||||
are lookupable, so a right answer is worth what it's worth *when you give it*:
|
||||
the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10
|
||||
over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out
|
||||
of time loses exactly as much as being wrong. A timeout that merely cost you the
|
||||
speed bonus would make "look it up in the other tab" the strongest way to play.
|
||||
The countdown in the browser is decoration; the clock that scores is
|
||||
`time.Now()` against the `AskedAt` the server stamped. A reload does not restart
|
||||
it.
|
||||
- **A pure reducer still, but the time is an argument** — `ApplyMove(state, move,
|
||||
now)`. A reducer cannot own a timer, so it doesn't: the only thing that knows
|
||||
what o'clock it is remains the caller, and the engine stays value-in, value-out.
|
||||
- **You cannot walk off the first rung** (`ErrNothingBanked`). If you could, seeing
|
||||
question one and walking would be a free look: stake, peek, walk, restake, and
|
||||
reshuffle until the question is one you happen to know. The first question is the
|
||||
price of sitting down.
|
||||
- **The browser never learns which answer is right.** The four answers cross the
|
||||
wire without the index; that index is in the engine state, on the server. It
|
||||
comes back only in the event that *decides* the question, by which point knowing
|
||||
it is worth nothing. The ladder's remaining questions are never sent at all.
|
||||
- `internal/games/trivia` — engine, 11 tests. The one that matters most is the
|
||||
same one hangman needed: the number the felt quotes (`Pays()`) is asserted equal
|
||||
to the number `settle()` lands on, at every rung.
|
||||
- **The bank is prefetched, not fetched per question** (`internal/opentdb`,
|
||||
`storage.DrawTrivia`, table `trivia_questions`). A ladder asks a question every
|
||||
fifteen seconds with money on a clock the player is scored against; a live fetch
|
||||
would put OpenTDB's latency and rate limit *inside* that clock. The refill is a
|
||||
slow background drip (`StartTriviaBank`, 400 per difficulty, one request per six
|
||||
seconds, stops early when a batch adds nothing new), and a round never waits on
|
||||
it. Answers are shuffled per-game against the game's own seed, so where the right
|
||||
answer sits in the table tells a player nothing.
|
||||
- **The dev rig seeds its own bank.** A fresh dev database has an empty bank and
|
||||
every start 503s, so `TestDevCasino` now takes one real batch per difficulty
|
||||
from OpenTDB (`seedTriviaBank`) — fifty questions each, four ladders' worth,
|
||||
through the same fetch-decode-store path production uses. It does *not* run
|
||||
`StartTriviaBank`: a rig that spends its first two minutes dripping four hundred
|
||||
questions per difficulty out of a free API is a rig you cannot use.
|
||||
- **Driven in a browser, 2026-07-14, and the clock and the money hold up.** The
|
||||
ladder plays: a 200 stake on Easy dealt a real OpenTDB question with its
|
||||
entities decoded, the clock bar drained honestly (847px → 711px over three
|
||||
seconds, countdown 18.7s → 15.7s), two right answers compounded 1.00× → 1.26×
|
||||
→ 1.58×, and walking paid exactly the 311 the felt had been quoting. The
|
||||
reveal marks the wrong pick red and the right answer green. A reload mid-rung
|
||||
brought the board back and — the thing that matters — the server's clock kept
|
||||
running through it (17.5s left before, 16.2s after; it does not restart).
|
||||
**The timeout lands as a timeout**, which was the loudest worry: going quiet
|
||||
through a 20s question fired the auto-submit at zero, came back 200 with a
|
||||
`timeout` event and "Out of time.", not a "that move isn't legal". The next
|
||||
question's answer is never sent (`correct: -1` in the ask event); only the
|
||||
decided one reveals.
|
||||
- **Two bugs, and only a browser could have found either.**
|
||||
1. **The spot printed double the stake after every settled game.** `standing()`
|
||||
set `spot.amount` and *then* poured the chips on, and `pour` grows the pile
|
||||
from whatever it is told is already there — so a 200 stake settled to a spot
|
||||
reading 400, and a 400 one to 800. This is exactly the rule the felt is built
|
||||
on ("the number under the pile is a readout of the pile") failing quietly:
|
||||
the money was always right, the *number under the chips* was not. Blackjack
|
||||
and hangman pour without pre-setting; trivia now does too.
|
||||
2. **The house rack sat on top of the multiplier at 390px.** The rack is a 147px
|
||||
block inset 5.75rem from the edge, and that inset is not a margin — it is the
|
||||
width of *blackjack's shoe*, which the rack sits beside. On a phone that puts
|
||||
it in the middle of the felt, on top of trivia's "1.53×". On small screens the
|
||||
rack now shrinks and, where there is nothing in the corner, pulls into the
|
||||
corner. Which rack is which is what `data-at` says: unmarked is alone in the
|
||||
corner, `shoe` is blackjack (pull that one to the edge and it slides under the
|
||||
deck — this was caught after doing exactly that), `rail` is solitaire, whose
|
||||
rack isn't on the felt at all. All four tables re-checked at 390px and 1280px,
|
||||
live games on the felt: no overlap with text, no overlap with the shoe, no
|
||||
horizontal overflow, desktop geometry unchanged.
|
||||
|
||||
- **UNO, and it plays for chips.** *(2026-07-14. Built, tested, and now **played** —
|
||||
see "Driven in a browser" at the bottom of this entry, which is where the three
|
||||
bugs were.)*
|
||||
- **You beat the table, or you don't.** The user's call between three money
|
||||
models: stake once, go out first and take the tier's multiple; anybody else
|
||||
going out first takes the stake. **The table size is the tier**, which is the
|
||||
one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full
|
||||
House (3 bots, 3.6×). Rake on winnings only, as everywhere.
|
||||
- **The multiples are measured, not guessed.** A player who just plays the first
|
||||
legal card they hold goes out first 43% / 32% / 27% of the time against the
|
||||
bots, so the tiers are priced to make that lose about 8% a game — which leaves
|
||||
good play (holding the wilds back, dumping the colour you're long in) worth
|
||||
roughly the house's edge. The measurement is a throwaway test, not in the tree;
|
||||
re-run it if the bots or the tiers change, because the two are a pair.
|
||||
- **The bots move inside `ApplyMove`, and that is what keeps solo UNO off a
|
||||
socket.** One request plays your move *and every bot turn it hands off to*,
|
||||
and returns the lot as a script of events the felt plays back in order. §7 said
|
||||
solo first, no sockets; this is what that costs.
|
||||
- **The RNG is in the state, not an argument.** The bots choose and a spent deck
|
||||
reshuffles, so the engine needs randomness *mid-game* — but there is no rng
|
||||
alive across requests to hand it. So the seed rides in the state (which never
|
||||
leaves the server; the deck is in there too) and each step derives its own
|
||||
generator from the seed and the step count. Value in, value out, and a game
|
||||
still replays exactly as it fell.
|
||||
- **The zero value of `Color` is Wild, deliberately.** It was Red for an hour, and
|
||||
a wild played with the `color` field simply missing from the JSON went down as
|
||||
a red one. The zero has to be "no colour named", so the omission is refused
|
||||
instead of quietly meaning something. This is the kind of bug a rules test
|
||||
finds and a browser never would.
|
||||
- **The browser never sees a bot's card.** Not the deck, not a hand, not even the
|
||||
face of a card a bot drew — that last one is most of the deck, and sending it
|
||||
would turn counting cards into reading the network tab. Seats cross the wire as
|
||||
a name and a *count*. There are two walls: the engine only attaches a face to
|
||||
an event the seat may see it in, and `viewUnoEvents` drops it again anyway.
|
||||
- `internal/games/uno` — engine, 22 tests. The census one is the load-bearing one:
|
||||
108 cards, each in exactly one place, asserted after every move of 100 games
|
||||
played out end to end. It is what would catch a reshuffle that leaks cards (the
|
||||
wilds go back into the deck as *wilds*, not as the colour they were played as)
|
||||
or a turn the bots never hand back.
|
||||
- `PeteFX.flyNode` — the throw, with the chip taken out of it. `fly()` is now that
|
||||
with a chip in it, because UNO wanted the same arc with a card in it. Extracted
|
||||
rather than copied, same as `casino-cards.js` and `PeteFX.spot()` before it.
|
||||
- The felt has no corner free for the house rack (bots along the top, piles in the
|
||||
middle, your hand at the bottom), so it takes solitaire's **rail** instead:
|
||||
`data-at="rail"`, off the felt, no collision to check for.
|
||||
- **Driven in a browser, 2026-07-14, and it plays.** A Full House game went the
|
||||
distance: the bots' turns come back as a readable script (a card flies from the
|
||||
seat that played it, SKIPPED and +2 land on somebody), the wild picker takes a
|
||||
colour and the felt changes to it, a reload mid-game brings back the hand, the
|
||||
counts, the colour in play and the stake, and the money is right — a Duel staked
|
||||
200 and won paid 428 back into a 4,600 stack (2.2× is 240 of winnings, less the
|
||||
5% rake, so +228 net), while a lost Full House took the stake and nothing else.
|
||||
A thirteen-card hand wraps to three rows at 390px with no sideways overflow and
|
||||
nothing colliding. Console silent.
|
||||
- **Three bugs, and the first one was the whole table.**
|
||||
1. **Every visit to `/games/uno` was a 500.** The handler was wired, the route
|
||||
was in `casinoRoutes()`, the template was written — and `uno` was never added
|
||||
to the list of pages `server.go` parses into the games template set, so
|
||||
`render()` answered "unknown page". No Go test saw it because the casino tests
|
||||
all call the handlers *directly* and never go through `render()`. There is now
|
||||
a test that does: `TestEveryCasinoPageRenders` walks the mux, asks for every
|
||||
page the casino routes to, and fails on a 500 or a half-rendered body. **Add a
|
||||
game, add it there.**
|
||||
2. **The wrong card left your hand.** The play script hid `.pete-uno-card[data-on="1"]`
|
||||
— the *first* card that lit up, not the one you clicked — so playing any other
|
||||
playable card made an innocent one vanish while the card you played sat there
|
||||
and a copy of it flew to the discard. It self-corrected on the re-render, which
|
||||
is why it read as a flicker rather than a bug. The index you played is now kept
|
||||
(`played`) and that card is the one lifted out.
|
||||
3. **On a phone the card in play sat on top of the colour in play.** The mobile
|
||||
query shrank `.pete-uno-discard`'s *box* with a raw height and width, but the
|
||||
card inside it is a `.pete-uno-card` and takes its size from `--uno-h`/`--uno-w`,
|
||||
which the discard never set — so a full-size card hung out of a small hole and
|
||||
covered the RED/BLUE pill under it. The vars go on the discard now. Worth
|
||||
remembering as a rule: **size a card by its vars, never by the box you put it
|
||||
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
|
||||
|
||||
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
|
||||
five are sitting on main un-deployed — the live casino is blackjack and nothing
|
||||
else. The server runs `StartTriviaBank`, so trivia's bank fills itself once the
|
||||
binary is out there, but the first player to try a ladder in the first minute
|
||||
after a deploy gets the 503.
|
||||
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
|
||||
no-mercy)" and only normal was ever built. gogobee has the rules
|
||||
(`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 hold'em, none of it blocking: the policy is **heads-up**, so a
|
||||
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%
|
||||
at six seats, and the rest is pot odds) — a multiway policy would want its own
|
||||
training run with more than two seats in the tree. Blackjack still has no **split**.
|
||||
|
||||
### How the browser half fits together
|
||||
|
||||
- `GET /games` (lobby), `GET /games/blackjack` (table) — signed-in only. On the games
|
||||
host, the mux prefixes `/games` onto the path, so the lobby is that host's `/`. Shared
|
||||
paths (`/api/`, `/auth/`, `/static/`) mean the same thing on every host and are left
|
||||
alone.
|
||||
- `GET /api/games/table`, `POST /api/games/{buyin,cashout}`,
|
||||
`POST /api/games/blackjack/{deal,move}` — session-authed, JSON, all returning the same
|
||||
`tableView` so the money and the felt can never disagree.
|
||||
- **The browser never sees the shoe.** The dealer's hole card is *absent* from the
|
||||
payload — not flagged hidden — until the reveal, and the deck lives only in
|
||||
`game_live_hands`. The response carries the engine's events (one per card off the
|
||||
shoe), which is what the table plays back as an animation.
|
||||
- Money order-of-operations: stake leaves the stack *before* the hand is dealt, in the
|
||||
same statement that checks it's there; the hand is *seated* (a plain INSERT on the
|
||||
primary key) before it can settle, which is what makes a double-clicked Deal a 409 with
|
||||
the stake refunded rather than a silently overwritten hand.
|
||||
|
||||
### Notes for whoever picks this up
|
||||
|
||||
- SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an
|
||||
open transaction deadlocks against itself. Do the pre-work before `Begin`.
|
||||
- **A buy-in can currently take a player into debt.** `DebitIdem` inherits
|
||||
`BLACKJACK_DEBT_LIMIT` (default −1000), so someone with an empty wallet can buy
|
||||
€1,000 of chips, win, and cash out while still €1,000 down. That is exactly what
|
||||
gogobee's Matrix blackjack already allows, so it is consistent rather than a bug —
|
||||
but a web casino runs far more hands, and this is the knob to turn if the economy
|
||||
starts leaking. A buy-in-specific floor of 0 is a two-line change.
|
||||
- gogobee's blackjack taxes 5% of the *gross* payout into a community pot
|
||||
(`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and
|
||||
gentler; don't "fix" one to match the other without deciding which is right.
|
||||
|
||||
---
|
||||
|
||||
## 1. The three constraints everything else follows from
|
||||
|
||||
**gogobee owns the euros.** The ledger is `euro_balances` / `euro_transactions`
|
||||
(gogobee `internal/db/db.go:1316,1324`), tied to the wider economy — adventure, shop,
|
||||
lottery, mischief. Pete does not get a second wallet. Pete never writes a balance.
|
||||
|
||||
**gogobee has no inbound API and isn't getting one.** The only listening socket in the
|
||||
whole repo is the Matrix appservice transaction endpoint (`internal/bot/appservice.go:255`).
|
||||
Pete's own source says it plainly (`internal/web/roster.go:23`):
|
||||
|
||||
> Direction of travel is gogobee → Pete ... Pete has no route back into the game box's
|
||||
> network and we are not opening one.
|
||||
|
||||
So gogobee stays the only initiator. It **polls** Pete for work and **pushes** results
|
||||
back through the existing durable queue. Same as mischief (`gogobee_mischief_plan.md:191-197`).
|
||||
|
||||
**One binary.** Games live in the Pete process. gogobee already runs ~50 plugins and six
|
||||
games in one process with in-memory table state and it's fine. Caddy points
|
||||
`games.parodia.dev` at the same port; the mux branches on Host.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity — free, no link codes
|
||||
|
||||
MAS imports the Authentik `preferred_username` as the Matrix localpart
|
||||
(`gogobee_mischief_plan.md:176-186`). So an Authentik session on Pete maps to a Matrix
|
||||
user deterministically:
|
||||
|
||||
```
|
||||
OIDC preferred_username -> strings.ToLower(u) -> @<u>:parodia.dev
|
||||
```
|
||||
|
||||
Pete's `SessionUser` (`internal/web/auth.go`) carries `Sub`/`Name`/`Email` today. Add
|
||||
`PreferredUsername` to the claims struct and the signed cookie payload. That is the whole
|
||||
identity story.
|
||||
|
||||
Note the existing precedent: `email_nag.go:52` already asserts "Authentik usernames ==
|
||||
Matrix localparts".
|
||||
|
||||
---
|
||||
|
||||
## 3. Money — session escrow, not per-hand settlement
|
||||
|
||||
### Why not settle each hand
|
||||
|
||||
Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares.
|
||||
A blackjack hand cannot work that way. If every bet round-trips through a poll loop you
|
||||
wait half a minute to be dealt, and again for the payout.
|
||||
|
||||
### The model
|
||||
|
||||
Borrow the semantics hold'em already uses — `euro.Debit(..., "holdem_buyin")`
|
||||
(`holdem.go:319`), `euro.Credit(..., "holdem_cashout")` (`holdem.go:371`) — and apply it
|
||||
to the whole web casino:
|
||||
|
||||
1. **Buy in.** You convert euros to *chips* for a games session. One debit. Tolerates poll latency.
|
||||
2. **Play.** Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite.
|
||||
Zero economy calls in the hot path.
|
||||
3. **Cash out.** Chips convert back to euros. One credit. Tolerates the same latency.
|
||||
|
||||
Two economy touches per *session* instead of two per *hand*. Poll latency stops mattering.
|
||||
|
||||
### The invariant
|
||||
|
||||
> A euro is either in gogobee's `euro_balances` or in Pete's chip escrow. Never both.
|
||||
> It moves between them only via a GUID-idempotent claim.
|
||||
|
||||
Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes
|
||||
stale. The authoritative check is `euro.Debit` at claim time. This preserves
|
||||
`gogobee_mischief_plan.md:198-202` — *"Pete never writes a balance, so no double-spend surface."*
|
||||
|
||||
### The prerequisite: euro idempotency (BLOCKING)
|
||||
|
||||
`euro_transactions` (`db.go:1324`) has **no external id and no unique constraint**. `Debit`
|
||||
is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only
|
||||
because every caller is a Matrix message that arrives once. A *retrying poll loop* breaks
|
||||
that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player
|
||||
pays twice.
|
||||
|
||||
**Before any of this ships:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE euro_transactions ADD COLUMN external_id TEXT;
|
||||
CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id)
|
||||
WHERE external_id IS NOT NULL;
|
||||
```
|
||||
|
||||
plus `CreditIdem(userID, amount, reason, externalID)` / `DebitIdem(...)` in `euro.go` that
|
||||
do the balance mutation and the transaction insert **in one tx**, and treat a unique-violation
|
||||
on `external_id` as success-already-applied. Everything web-initiated goes through these.
|
||||
|
||||
---
|
||||
|
||||
## 4. The wire protocol
|
||||
|
||||
All new Pete endpoints are bearer-authed with the existing ingest token
|
||||
(`internal/web/adventure.go:307` `bearerOK`). gogobee grows its first GET path in
|
||||
`internal/peteclient/client.go` — the poll loop the mischief plan already calls for.
|
||||
|
||||
### Pete serves (gogobee polls, ~3s interval)
|
||||
|
||||
```
|
||||
GET /api/games/escrow/pending -> [{guid, matrix_user, kind: buyin|cashout, amount}]
|
||||
POST /api/games/escrow/claim <- {guid} idempotent, marks claimed
|
||||
```
|
||||
|
||||
### gogobee pushes (existing peteclient queue, guid-idempotent)
|
||||
|
||||
```
|
||||
POST /api/games/escrow/settled -> {guid, ok: bool, reason?: "insufficient_funds", balance_after}
|
||||
```
|
||||
|
||||
Reuse `pete_emit_queue` (`client.go:121-125`, `INSERT OR IGNORE` on `guid` PK) — it already
|
||||
does durability, backoff and parking. Don't build a second queue.
|
||||
|
||||
### State machine (Pete side, table `game_escrow`)
|
||||
|
||||
```
|
||||
requested -> claimed -> funded (buyin ok; chips become spendable)
|
||||
-> rejected (insufficient funds; nothing spendable)
|
||||
requested -> claimed -> settled (cashout ok; chips destroyed, euros credited)
|
||||
```
|
||||
|
||||
Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of
|
||||
"buying chips…" is acceptable; 30s is not.
|
||||
|
||||
### The reaper
|
||||
|
||||
Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for
|
||||
30 minutes. A crashed Pete must reconcile on boot: any `claimed` escrow with no `settled`
|
||||
push gets re-polled by GUID.
|
||||
|
||||
---
|
||||
|
||||
## 5. Code reuse — copy, don't share
|
||||
|
||||
Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores
|
||||
into `pete/internal/games/`, let them drift, no shared module.
|
||||
|
||||
### Verdict per game
|
||||
|
||||
| Game | Copy | Rewrite | Notes |
|
||||
|---|---|---|---|
|
||||
| **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. |
|
||||
| **Hangman** | ~250 LOC | loading/persistence | Clean rune-safe state machine. |
|
||||
| **Blackjack** | ~95 LOC | everything else | 95 lines is the entire core. |
|
||||
| **Trivia** | ~80 LOC | everything else | **No question bank exists.** |
|
||||
|
||||
### 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:
|
||||
|
||||
- `holdem_cfr.go` (1,285) — CFR trainer + NPC policy runtime, info-set packing into a
|
||||
`uint64`, regret pruning, board-texture/SPR/equity bucketing. The *bucketing* is worth
|
||||
taking (equity, SPR, board texture). The trainer, the tree and the trained policy are
|
||||
not: see the warning above.
|
||||
- `holdem_equity.go` + `holdem_equity_range.go` (548) — Monte-Carlo equity, equity-vs-range,
|
||||
draw/out detection. 100% pure, well tested.
|
||||
- `holdem_betting.go` (383) — side pots, min-raise, all-in, street completion. The fiddly
|
||||
poker rules you do not want to rewrite. **Untested in gogobee — write tests as you port.**
|
||||
- `holdem_game.go`, `holdem_eval.go`, `holdem_render.go`.
|
||||
|
||||
Entanglements to break (mechanical):
|
||||
1. `id.UserID` → `PlayerID string` (`holdem_betting.go:283,314`; `holdem_eval.go` winnings maps).
|
||||
2. Delete `RoomID`/`DMRoomID` from `HoldemGame` — table identity belongs to the shell.
|
||||
3. Hoist the four `*time.Timer` fields out of `HoldemGame` (`holdem_game.go:92-95`).
|
||||
4. `LoadPolicy(path)` does `os.Open` → `LoadPolicyFrom(io.Reader)`, so the policy can be `embed.FS`'d.
|
||||
|
||||
Hand evaluation is **not** homegrown — `holdem_eval.go:12` wraps `poker.Evaluate`. Just take
|
||||
the `github.com/chehsunliu/poker` dependency.
|
||||
|
||||
### UNO — lift the primitives, rewrite the engine
|
||||
|
||||
Copy verbatim (already unit-tested in `uno_test.go`):
|
||||
- `unoCard`/`unoColor`/`unoValue`, `canPlayOn`, `newUnoDeck`, draw/reshuffle (`uno.go:21-364`)
|
||||
- The bot AI as free functions: `botPickCard`, `botPickNormal`, `botPickAggressive`,
|
||||
`botPickColor` (`uno.go:1465-1585`)
|
||||
- `uno_nomercy.go` is ~90% pure: scoring, stacking rules, no-mercy deck, second bot.
|
||||
|
||||
**Rewrite the turn engine.** In gogobee the engine *is* the message sender —
|
||||
`executeMultiTurn`, `applyAndAnnounce`, `handlePlayerPlay` mutate state and call
|
||||
`p.SendReply(...)` mid-turn, and their `error` returns mean "send failed", not "illegal move".
|
||||
There is no `ApplyMove(game, move) (Result, error)` seam anywhere. Disentangling that costs
|
||||
more than rewriting it against the (good) primitives. One near-seam worth keeping:
|
||||
`applyCardEffects` (`uno_multi.go:1459`) already returns a struct instead of sending.
|
||||
|
||||
### Hangman — take the struct
|
||||
|
||||
`hangmanGame` + `guessLetter`/`guessSolution`/`displayPhrase` + the `gallows [7]string` ASCII
|
||||
art (`hangman.go:26-274`). Strip three fields (`participants`, `solvedBy`, `threadID`). Copy
|
||||
`hangman_phrases.txt` (237 lines) and `embed` it instead of `os.Getenv("HANGMAN_PHRASE_FILE")`.
|
||||
Drop the dreamclient translation path for v1.
|
||||
|
||||
### Blackjack — retype it
|
||||
|
||||
`handValue` (correct soft-ace demotion), `isBlackjack`, and their tests. That's it — 95 lines.
|
||||
The rest is `bjTable` keyed by `id.RoomID` with timers embedded, and raw `db.Exec` SQL at
|
||||
`blackjack.go:867`.
|
||||
|
||||
### Trivia — the question bank does not exist
|
||||
|
||||
`trivia.go:288` fetches from OpenTDB live, one question per round:
|
||||
|
||||
```go
|
||||
apiURL := "https://opentdb.com/api.php?amount=1"
|
||||
```
|
||||
|
||||
Reuse the category map (`trivia.go:24-53`) and `calculateScore` (time-decay, `:536`). For the
|
||||
web version, **pre-fetch and cache a bank locally** — a per-question HTTP call in a web game
|
||||
loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route
|
||||
outbound fetches through Pete's `internal/safehttp` (SSRF guard).
|
||||
|
||||
Trivia has **no euro coupling today** (points only). Keep it that way in v1 — it's the one
|
||||
game that can ship with zero escrow risk.
|
||||
|
||||
### Two things that apply to every copied engine
|
||||
|
||||
**Thread the RNG.** Every card game uses the `math/rand/v2` package global —
|
||||
`blackjack.go:60`, `uno.go:186,277`, `holdem_game.go:102`, and throughout the CFR/Monte-Carlo
|
||||
code. Nothing is seedable, which is why `TestBotPickCard_*` can only assert weak properties.
|
||||
The adventure half of gogobee already does this right (`dnd_zone_combat.go:361` threads an
|
||||
explicit `*rand.Rand` via `rand.NewPCG`). The card games never adopted it. **Threading
|
||||
`rng *rand.Rand` through the deck constructors is mandatory, not optional** — ~20 call sites,
|
||||
and it's the difference between a testable engine and one you can only smoke-test. It also
|
||||
gives you a reproducible shuffle for dispute resolution.
|
||||
|
||||
**Hoist the timers.** `bjTable.joinTimer/turnTimer/reminderTimers`, `unoGame.idleTimer/warningTimer`,
|
||||
`HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer` — all live inside the game
|
||||
structs today. Timers are a shell concern. Game state must be a plain value you can serialize,
|
||||
which is also what makes restart-mid-hand survivable.
|
||||
|
||||
### Build a `cards` package while you're at it
|
||||
|
||||
There is **no shared cards package in gogobee** — blackjack has its own deck
|
||||
(`blackjack.go:32-75`), UNO has its own (`uno.go:130-189`), hold'em uses the third-party lib.
|
||||
Consolidate into `pete/internal/games/cards` during the port rather than importing the
|
||||
duplication.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architecture in Pete
|
||||
|
||||
```
|
||||
internal/games/
|
||||
cards/ shared deck primitives (new; consolidates gogobee's duplicates)
|
||||
blackjack/ pure engine — ApplyMove(state, move) (state, events, error)
|
||||
holdem/ pure engine + cfr/ (copied) + policy.gob (embedded)
|
||||
uno/ pure engine (rewritten) over copied primitives + bots
|
||||
hangman/ pure engine (copied) + phrases.txt (embedded)
|
||||
trivia/ pure engine (new) + cached question bank
|
||||
escrow/ chip ledger, the gogobee poll/push seam
|
||||
table/ session, seating, turn clocks, reconnect — the shell
|
||||
```
|
||||
|
||||
**Server-authoritative, always.** The browser sends intents and never sees the deck. Any
|
||||
game with money attached cannot trust a client-reported result. This is why the engines have
|
||||
to be Go on Pete's side rather than ported to JS.
|
||||
|
||||
**Every engine is a pure reducer**: `ApplyMove(state, move) (newState, []Event, error)`.
|
||||
Timers, sockets and persistence all live in `table/`. That's the seam gogobee never had, and
|
||||
it's what buys testability, replay, and surviving a redeploy.
|
||||
|
||||
### Transport
|
||||
|
||||
- **Blackjack, Hangman, Trivia, UNO-solo** — request/response over `fetch`. No sockets.
|
||||
- **Hold'em, UNO-multi** — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand,
|
||||
spectators. This is the bulk of the total effort, and it is the only genuinely new
|
||||
infrastructure in the project.
|
||||
|
||||
### Frontend
|
||||
|
||||
Pete has **no SPA and no bundler** today — server-rendered `html/template` + `embed.FS`, plain
|
||||
`<script defer>` tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first
|
||||
real client-side app in the repo.
|
||||
|
||||
Precedent says this is survivable: `weather-gl.js` is 1,028 lines of hand-written WebGL2 with
|
||||
no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for
|
||||
layout. Revisit only if it actually hurts.
|
||||
|
||||
### Auth
|
||||
|
||||
Session cookie is host-only today — `auth.go:151` sets `Path` but no `Domain`, so a
|
||||
`news.parodia.dev` session will not travel to `games.parodia.dev`. Set `Domain: ".parodia.dev"`.
|
||||
Note this widens the cookie to every parodia.dev host including the landing site — a deliberate
|
||||
loosening, fine here, but not a freebie. Add the `games.parodia.dev` redirect URI to the `pete`
|
||||
app in Authentik.
|
||||
|
||||
Games require login. No anonymous play — there's money in it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ship order
|
||||
|
||||
**Phase 0 — euro idempotency (gogobee).** `external_id` column + unique index +
|
||||
`CreditIdem`/`DebitIdem`. Blocking; nothing else is safe without it.
|
||||
|
||||
**Phase 1 — escrow + Blackjack.** The full money loop against the simplest possible game
|
||||
(95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity
|
||||
mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all
|
||||
against a game where the *game* cannot be what's broken.
|
||||
|
||||
**Phase 2 — Trivia + Hangman.** No escrow (trivia has no euro coupling; keep hangman's
|
||||
collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the
|
||||
site feel like a place rather than a demo.
|
||||
|
||||
**Phase 3 — UNO.** Solo first (single-player vs bot, no sockets). Then multi, which is where
|
||||
the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place
|
||||
to learn multiplayer.
|
||||
|
||||
**Phase 4 — Hold'em.** Last. It's the hardest engine (side pots, all-ins, split pots), the
|
||||
biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it
|
||||
when the multiplayer plumbing has already survived contact with real players.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
**Economy inflation.** A web casino runs orders of magnitude more hands per hour than
|
||||
Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both
|
||||
directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or
|
||||
a rake. This is the risk most likely to be discovered too late.
|
||||
|
||||
**Restart mid-hand.** Game state is in memory, so a Pete redeploy kills live tables — the same
|
||||
property gogobee has today, and it redeploys far less often than Pete does. Mitigate with
|
||||
serializable state (which the pure-reducer design gives for free) plus a drain-before-restart,
|
||||
not a second process.
|
||||
|
||||
**Collusion in hold'em.** Two browsers, one person, one table. Not solvable in v1; at minimum
|
||||
log seat/IP/session overlap so it's *detectable* after the fact.
|
||||
|
||||
**The gogobee contract is cross-repo.** `roster_test.go` already guards it: an unknown
|
||||
`event_type` is a 400 that makes gogobee's sender park the row. Add the same guard on the new
|
||||
escrow endpoints, and keep the payload structs in step across both repos by hand — that's the
|
||||
cost of copying instead of sharing, and it's the right trade here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
- **Chips 1:1 with euros, or a separate denomination?** 1:1 is simpler and honest. A separate
|
||||
denomination gives you a knob for the inflation problem.
|
||||
- **Do web results feed the Matrix room?** Pete already has a priority poster
|
||||
(`adventure.go:151`). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
|
||||
- **NPC opponents at the web tables?** The CFR bot is right there and it's good. It also means
|
||||
a table never sits empty, which matters a lot for a small community.
|
||||
Reference in New Issue
Block a user