A code review of the uno table found the stuck guard had never once fired. It counted how many bots had passed in a row and wanted more of them than there are seats — but the bot loop hands the turn back the moment it comes round to you, so the count could never get there, and your own empty-handed pass was never in it. A dead table just passed the turn round forever. That is not an ugly ending, it's a game you cannot finish, and a game you cannot finish is chips you cannot cash out. So it asks the real question now: is there anything to draw, and is anyone holding a card that goes. And the table let go of itself too early. busy came off when the request landed, not when the script it came back with had finished playing — so for the seconds a bot lap takes, you could click a card at a board the server had already moved past. It comes off at the end now, like the other tables. Also: left: 0 was being dropped on its way out the door, which is the one number that matters (the seat that just went out), the deck counter didn't come back after a reshuffle, and hoisting fly() into flyNode() had quietly flattened the chip arc on every other table in the room. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
828 lines
26 KiB
Go
828 lines
26 KiB
Go
// 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)))
|
|
}
|