games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
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",
|
||||
}
|
||||
810
internal/games/uno/uno.go
Normal file
810
internal/games/uno/uno.go
Normal file
@@ -0,0 +1,810 @@
|
||||
// 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,omitempty"` // 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)
|
||||
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 or the turn comes back round.
|
||||
func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
|
||||
// A bot that can neither play nor draw passes, and if every seat does that in
|
||||
// a row the game is stuck: the deck is spent and the discard has nothing under
|
||||
// its top card to become a new one. Rare, but a game that can't end is worse
|
||||
// than one that ends badly, so it ends — see stuck().
|
||||
passes := 0
|
||||
for s.Phase != PhaseDone && s.Turn != You {
|
||||
if s.botTurn(s.Turn, evs, rng) {
|
||||
passes++
|
||||
if passes > len(s.Hands) {
|
||||
s.stuck(evs)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
passes = 0
|
||||
}
|
||||
}
|
||||
|
||||
// botTurn plays one bot's turn. It reports whether the bot passed with nothing.
|
||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) {
|
||||
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) {
|
||||
card, idx = drawn[0], len(s.Hands[seat])-1
|
||||
} else {
|
||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||
s.advance(1)
|
||||
return len(drawn) == 0 // a bot that couldn't even draw is a stuck table
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return false
|
||||
}
|
||||
|
||||
// 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)))
|
||||
}
|
||||
736
internal/games/uno/uno_test.go
Normal file
736
internal/games/uno/uno_test.go
Normal file
@@ -0,0 +1,736 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user