// Package uno is a pure UNO engine, played for chips at a shared table. // // Same seam as the other tables: ApplyMove(state, seat, 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. // // UNO is a session, not a game — the shape hold'em already ships. You sit down // with a stack of chips, and every hand each seat antes into a pot that the // winner takes, less the house's rake. Chips cross the border exactly twice, at // sit-down and get-up; in between, a hand settles by moving the pot between seat // stacks inside this blob and credits nobody. A table is a list of seats, and // who is human is a property of each seat, not of its index — solo play is just // a table nobody else has joined. // // Two things make UNO different from the other felts. // // The bots move inside ApplyMove. A turn-based game against opponents is normally // where you reach for a socket: one call from a human plays their move *and* // every bot turn that follows it, up to the next human's decision, and hands back // the whole run as events. The table animates them in order. // // 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") ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it") ErrNoStack = errors.New("uno: there's no stack to take") ErrNoCatch = errors.New("uno: there's nobody to catch there") ErrUnknownMove = errors.New("uno: unknown move") ErrUnknownTier = errors.New("uno: no such tier") ErrHandLive = errors.New("uno: a hand is in progress") ErrNoHand = errors.New("uno: there's no hand in progress") ErrTableFull = errors.New("uno: the table is full") ErrSeatTaken = errors.New("uno: that seat is taken") ErrBadBuyIn = errors.New("uno: that isn't a legal buy-in for this table") ) // HandSize is the deal. Seven each, as printed on the box. const HandSize = 7 // MaxSeats is the biggest a table gets. The bot pool is larger, so a full table // never has two of the same name. const MaxSeats = 8 // 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 // The faces. The first fifteen are the ones on a normal box, and their numbers // are load-bearing: a game in flight is a JSON blob of these integers, so the No // Mercy faces are *appended*. Renumbering them would deal a live table a // different card. const ( Zero Value = iota One Two Three Four Five Six Seven Eight Nine Skip Reverse DrawTwo WildCard WildDrawFour // No Mercy only, all of them. SkipAll // skip everyone: you go again DrawFour // a *coloured* +4, which the normal deck doesn't have DiscardAll // play it, and every other card of its colour goes with it WildRevFour // reverse, and the seat that lands next takes four WildDrawSix // +6 WildDrawTen // +10 WildRoulette // the next seat flips until your colour turns up, and keeps the lot ) var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "skip", "reverse", "+2", "wild", "+4", "skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"} func (v Value) String() string { if v > WildRoulette { return "?" } return valueNames[v] } // Action reports whether a card does something beyond being a number. func (v Value) Action() bool { return v >= Skip } // Wild reports whether the face has no colour of its own. Note DrawFour is *not* // one: No Mercy prints a coloured +4, which is a different card from the wild +4 // sitting next to it in the same deck. func (v Value) Wild() bool { switch v { case WildCard, WildDrawFour, WildRevFour, WildDrawSix, WildDrawTen, WildRoulette: return true } return false } // Draw is how many cards the face makes somebody take, and zero if it doesn't. // It is also what makes a card stackable, so Roulette is deliberately zero: it // hands over a random number of cards, and you cannot stack onto a number nobody // knows yet. func (v Value) Draw() int { switch v { case DrawTwo: return 2 case DrawFour, WildDrawFour, WildRevFour: return 4 case WildDrawSix: return 6 case WildDrawTen: return 10 } return 0 } // 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.Wild() } // 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 — the 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. The table size is the difficulty — more seats is a longer // shot at being first out — and the ante is the stake. No Mercy rides on the same // struct rather than a second one, because it is the tier that lands in the state // and the payload, so a game carries which rules it is playing by and cannot be // reloaded into the other set. type Tier struct { Slug string `json:"slug"` Name string `json:"name"` Bots int `json:"bots"` Ante int64 `json:"ante"` // what every seat puts in the pot each hand MinBuy int64 `json:"min_buy"` // the smallest stack you may sit down with MaxBuy int64 `json:"max_buy"` // and the largest Blurb string `json:"blurb"` NoMercy bool `json:"no_mercy"` RakePct float64 `json:"rake_pct"` // set by New, so a state knows its own rake } // Deck is the deck this tier plays with. func (t Tier) Deck() []Card { if t.NoMercy { return NewNoMercyDeck() } return NewDeck() } // The stakes every UNO table plays for. There is no measured multiple any more — // the pot does the paying, the winner takes it less rake, and the house edge is // exactly the rake. All the tables share an ante and buy-in range; the tier is // only the table size. const ( tierAnte = 50 tierMinBuy = 500 // ten antes: enough to sit and play a while tierMaxBuy = 5000 // and the ceiling, so nobody buys the table ) // Tiers are the three normal-deck tables. var Tiers = []Tier{ {Slug: "duel", Name: "Duel", Bots: 1, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, Blurb: "One bot, head to head. A reverse is a skip with two at the table."}, {Slug: "table", Name: "Table", Bots: 2, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, Blurb: "Two bots. Twice the +4s pointed at you."}, {Slug: "full", Name: "Full House", Bots: 3, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, Blurb: "Three bots, and any of them going out first takes the pot."}, } // NoMercyTiers are the same three tables playing the other rules. var NoMercyTiers = []Tier{ {Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, Blurb: "One bot, 168 cards. Stack the draws or eat them."}, {Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, Blurb: "Two bots. A +10 answered twice is somebody's whole hand."}, {Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Ante: tierAnte, MinBuy: tierMinBuy, MaxBuy: tierMaxBuy, NoMercy: true, Blurb: "Three bots. Twenty-five cards and you're out of the hand."}, } // AllTiers is every table in the room, both dials. func AllTiers() []Tier { return append(append([]Tier(nil), Tiers...), NoMercyTiers...) } // TierBySlug finds a tier by the name the browser sent, across both rule sets. func TierBySlug(slug string) (Tier, error) { for _, t := range AllTiers() { if t.Slug == slug { return t, nil } } return Tier{}, ErrUnknownTier } // Phase is where the game is. type Phase string const ( PhaseHandOver Phase = "handover" // between hands; a Deal starts the next PhasePlay Phase = "play" // your turn, play or draw PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it PhaseDone Phase = "done" // a solo session has ended and the seat is cashing out ) // Outcome is how a hand ended. It describes the hand, not a seat — the winner is // carried separately. type Outcome string const ( OutcomeNone Outcome = "" OutcomeWon Outcome = "won" // a seat went out OutcomeStuck Outcome = "stuck" // nobody could move, and the shortest hand took it OutcomeTie Outcome = "tie" // stuck and level: the antes went back ) // Seat is one chair at the table. A shared table is seated by the runtime: humans // in the chairs people took, bots in the rest. type Seat struct { Name string `json:"name"` Bot bool `json:"bot"` Stack int64 `json:"stack"` // chips in front; a human's real, a bot's house money Waiting bool `json:"waiting,omitempty"` // joined mid-session; dealt in at the next hand Ante int64 `json:"ante,omitempty"` // what this seat put in the pot this hand Won int64 `json:"won,omitempty"` // what this seat took from the pot this hand, net of rake } // SeatConfig is one chair the table opens with — the shape New is seated from. type SeatConfig struct { Name string Bot bool Stack int64 } // State is one table. The seats' hands and the deck are in here, which is exactly // why this value never crosses the wire — a viewer gets their own hand and counts // for everyone else. type State struct { Tier Tier `json:"tier"` Seats []Seat `json:"seats"` Hands [][]Card `json:"hands"` // seat i's cards 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 Dealer int `json:"dealer"` // rotates each hand; the seat after it acts first // Out is the seats not in the current hand — mercy-killed, or sitting one out // because they couldn't cover the ante. The turn order steps over them. // Pending is the bill a stack of draw cards has run up: whoever stops stacking // pays it. Out []bool `json:"out,omitempty"` Pending int `json:"pending,omitempty"` // Called is who, holding one card, said so. It is only ever meaningful for a // seat on exactly one card — every other seat's entry is false and means // nothing — and it is what a catch is tested against. See call.go. Called []bool `json:"called,omitempty"` Pot int64 `json:"pot"` // the antes riding on the current hand Paid int64 `json:"paid"` // rake lifted from human-won pots, all session (the audit total) BoughtIn int64 `json:"bought_in"` // the sum of what the humans brought (audit stake total) 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"` Phase Phase `json:"phase"` HandNo int `json:"hand_no"` // The last hand's result, for the felt to land the verdict and the audit to // record. Winner is the seat that took the pot, or -1 for a refunded tie. Winner int `json:"winner"` LastPot int64 `json:"last_pot"` // gross pot the winner took Rake int64 `json:"rake"` // rake lifted from that pot Outcome Outcome `json:"outcome,omitempty"` // Payout is set only when a solo session ends (the one human gets up or busts): // the stack that crosses the border home. A shared table never reaches PhaseDone. Payout int64 `json:"payout,omitempty"` } // Event is something the table animates. The bots' turns arrive as a run of these // on the back of a human'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 one drawn 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 By int `json:"by"` // who caught them, on a catch. Seat zero is a real answer here, so never omitempty Text string `json:"text,omitempty"` // Hand is the acting seat's hand as it stands after this event, and it is only // ever set on an event that changed it. The engine stamps *every* seat's hand // (it cannot know who a shared stream is for); the web layer redacts it down to // the one hand the viewer is entitled to — the same wall hold'em's hole cards // live behind. A missed redaction there fans a hand to every subscriber. Hand []Card `json:"hand,omitempty"` } // mine stamps the acting seat's hand onto an event that just changed it. It is // stamped for *every* seat now, because a shared stream is watched by more than // one human and the engine cannot know which; the redaction that keeps a hand // private is at the web layer (viewUnoEvents), which strips every hand but the // viewer's own. See Event.Hand. func (s *State) mine(e Event) Event { if e.Seat >= 0 && e.Seat < len(s.Hands) { e.Hand = append([]Card(nil), s.Hands[e.Seat]...) } return e } // 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 face is present; the web layer redacts it. // 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, and its owner said so // caught a seat went down to one card *quietly*, and somebody noticed: +2 // miscall a seat called one that had nothing to hide, and paid for it: +2 // reshuffle the discard goes back under // ante a seat put its ante in the pot at the deal // settle the hand is over // // And the No Mercy ones: // // stack a draw card is pointed at a seat: N is the bill so far // skipall everybody else loses their turn // discard a whole colour left a hand at once // roulette a seat flipped N cards looking for a colour, and kept them // mercy a seat hit 25 cards and is out of the hand const ( EvDeal = "deal" EvPlay = "play" EvDraw = "draw" EvForced = "forced" EvPass = "pass" EvSkip = "skip" EvReverse = "reverse" EvUno = "uno" EvCaught = "caught" EvMiscall = "miscall" EvReshuffle = "reshuffle" EvAnte = "ante" EvSettle = "settle" EvStack = "stack" EvSkipAll = "skipall" EvDiscardAll = "discard" EvRoulette = "roulette" EvMercy = "mercy" ) // Move is what a seat sends: play a card, draw off the deck, decline a card you // drew, take a stack, catch a quiet seat, deal the next hand, or get up. type Move struct { Kind string `json:"kind"` // see below Index int `json:"index"` // which card of your hand, for a play Color Color `json:"color"` // the colour you name, for a wild Uno bool `json:"uno"` // "…and UNO!", for a play that leaves you on one card Seat int `json:"seat"` // whose silence you're calling, for a catch } // Move kinds. Take is No Mercy's: it is how you give in to a stack you can't // answer, and it is a *decision*, so it gets a name of its own rather than being // bolted onto draw. Catch is the other half of the UNO call (see call.go) — a // move you make out of turn order. Deal and Leave are the session moves: a hand // starts and a seat gets up, both only legal between hands. const ( MovePlay = "play" MoveDraw = "draw" MovePass = "pass" MoveTake = "take" MoveCatch = "catch" MoveDeal = "deal" MoveLeave = "leave" ) // New opens a table and seats it. No hand is dealt yet — the table opens on // PhaseHandOver, and the first Deal starts the first hand. Solo play is just the // case where exactly one chair is human. func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { if len(seats) < 2 || len(seats) > MaxSeats { return State{}, nil, ErrTableFull } t.RakePct = rakePct s := State{ Tier: t, Dir: 1, Winner: -1, Seed1: seed1, Seed2: seed2, RakePct: rakePct, Phase: PhaseHandOver, } var evs []Event for _, sc := range seats { if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) { return State{}, nil, ErrBadBuyIn } i := len(s.Seats) s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack}) if !sc.Bot { s.BoughtIn += sc.Stack evs = append(evs, Event{Kind: "sit", Seat: i, N: int(sc.Stack), Text: t.Name}) } } s.Hands = make([][]Card, len(s.Seats)) s.Out = make([]bool, len(s.Seats)) s.Called = make([]bool, len(s.Seats)) // The dealer starts on the last seat, so the first deal (which does not rotate // it) leaves the seat after it — seat zero — to act first. s.Dealer = len(s.Seats) - 1 return s, evs, nil } // SoloSeats builds the seat list for a table of one human and n bots — the shape // the solo handler opens. The human is seat zero and takes buyIn. func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig { return TableSeats(t, "You", bots, buyIn) } // TableSeats builds a table of one named human and n bots. The human takes seat // zero and their buy-in; each bot takes house chips (not real money) enough to // ante with, rebought as needed. See New. func TableSeats(t Tier, human string, bots int, buyIn int64) []SeatConfig { seats := []SeatConfig{{Name: human, Stack: buyIn}} for i := 0; i < bots && i < len(botPool); i++ { seats = append(seats, SeatConfig{Name: botPool[i], Bot: true, Stack: t.MaxBuy}) } return seats } // freeBotName picks a regular not already sitting at the table, so vacating a // seat never puts two of the same name on the felt. func (s *State) freeBotName() string { used := make(map[string]bool, len(s.Seats)) for i := range s.Seats { used[s.Seats[i].Name] = true } for _, n := range botPool { if !used[n] { return n } } return "The House" } // Vacate turns a human's chair back into the house's and returns the stack that // goes home with them. The seat keeps its place and its chips — which become house // money, rebought like any bot's — so the others play on without a hole in the // ring. It refuses mid-hand, because a seat with an ante in the pot cannot be // emptied without stranding it. func (s *State) Vacate(seat int) (int64, error) { if seat < 0 || seat >= len(s.Seats) { return 0, ErrUnknownMove } if s.Phase != PhaseHandOver && s.Phase != PhaseDone { return 0, ErrHandLive } p := &s.Seats[seat] if p.Bot { return 0, ErrUnknownMove } home := p.Stack p.Bot = true p.Name = s.freeBotName() p.Waiting = false return home, nil } // Occupy seats a human in a chair a bot was keeping warm, with the buy-in they // brought. Like Vacate it is a between-hands move — you cannot sit into a live // hand — and the seat waits out the current gap until the next deal brings it in. func (s *State) Occupy(seat int, name string, buyIn int64) error { if seat < 0 || seat >= len(s.Seats) { return ErrUnknownMove } if s.Phase != PhaseHandOver { return ErrHandLive } if !s.Seats[seat].Bot { return ErrSeatTaken } if buyIn < s.Tier.MinBuy || buyIn > s.Tier.MaxBuy { return ErrBadBuyIn } p := &s.Seats[seat] p.Bot = false p.Name = name p.Stack = buyIn p.Waiting = true // dealt in at the next hand s.BoughtIn += buyIn return nil } // ApplyMove is the engine. A seat's move goes in; that 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. // // seat is who is acting. A hand move is legal only from the seat whose turn it is // (a catch is the exception — it is out of turn by design); the session moves // (Deal, Leave) belong to the seat that sent them. This is the one place seat // identity enters the engine. func ApplyMove(s State, seat int, m Move) (State, []Event, error) { if seat < 0 || seat >= len(s.Seats) { return s, nil, ErrUnknownMove } if s.Phase == PhaseDone { return s, nil, ErrGameOver } switch m.Kind { case MoveDeal: if s.Phase != PhaseHandOver { return s, nil, ErrHandLive } next := s.clone() next.Step++ var evs []Event next.dealHand(&evs) next.resolve(&evs) return next, evs, nil case MoveLeave: // Getting up at a solo table ends the session and pays the stack out; the // runtime reads Payout and crosses the border. At a shared table leaving is a // storage operation and this branch is not the path taken — see the handler. if s.Phase != PhaseHandOver { return s, nil, ErrHandLive } next := s.clone() next.Phase = PhaseDone next.Payout = next.Seats[seat].Stack return next, []Event{{Kind: MoveLeave, Seat: seat, N: int(next.Payout)}}, nil } // A hand move. if !s.playing() { return s, nil, ErrNoHand } if s.Seats[seat].Bot { return s, nil, ErrNotYourTurn // bots move inside the engine, never through this door } if m.Kind != MoveCatch && s.Turn != seat { return s, nil, ErrNotYourTurn } next := s.clone() next.Step++ next.ensureCalled() rng := stepRNG(next.Seed1, next.Seed2, next.Step) var evs []Event var err error switch m.Kind { case MovePlay: evs, err = next.seatPlays(seat, m, rng) case MoveDraw: evs, err = next.seatDraws(seat, rng) case MovePass: evs, err = next.seatPasses(seat) case MoveTake: evs, err = next.seatTakes(seat, rng) case MoveCatch: evs, err = next.seatCatches(seat, m, rng) default: return s, nil, ErrUnknownMove } if err != nil { return s, nil, err // the caller's state, untouched } // Before anybody else moves: did the acting seat go down to one card without // saying so? This is the only window there is. The bots are about to take their // turns, and a bot that has played on is a bot that has stopped looking. next.botsCatch(seat, &evs, rng) next.resolve(&evs) return next, evs, nil } // resolve runs the bots out to the next human's decision and closes out a hand // that ended or died under them. It is the tail every move and every deal ends on. func (s *State) resolve(evs *[]Event) { rng := stepRNG(s.Seed1, s.Seed2, s.Step) s.runBots(evs, rng) if s.playing() && s.stalled() { s.stuck(evs) } s.tidyCalls() } // playing reports whether a hand is in progress — as opposed to between hands or // a solo session cashing out. func (s State) playing() bool { return s.Phase == PhasePlay || s.Phase == PhaseDrawn || s.Phase == PhaseStack } // dealHand starts a hand: it rotates the dealer, brings in whoever is waiting, // collects the antes, deals seven each and turns a card over. A seat that cannot // cover the ante sits the hand out; if that leaves fewer than two able to play, a // solo table's one human is bust and the session ends. func (s *State) dealHand(evs *[]Event) { if s.HandNo > 0 { s.Dealer = (s.Dealer + 1) % len(s.Seats) } // Reset per-hand state and work out who is in. s.Out = make([]bool, len(s.Seats)) s.Called = make([]bool, len(s.Seats)) s.Pending = 0 s.Pot = 0 s.Hands = make([][]Card, len(s.Seats)) s.Discard = nil s.Winner, s.LastPot, s.Rake, s.Outcome = -1, 0, 0, OutcomeNone humans := 0 var in []int for i := range s.Seats { p := &s.Seats[i] p.Waiting = false p.Ante, p.Won = 0, 0 if !p.Bot { humans++ } if p.Bot && p.Stack < s.Tier.Ante { p.Stack = s.Tier.MaxBuy // the house rebuys a bot that has run low } if p.Stack >= s.Tier.Ante { in = append(in, i) } else { s.Out[i] = true // sits this one out; can't cover the ante } } if len(in) < 2 { // Not enough chips at the table to play a hand. At a solo table that means // the one human is bust: end the session and pay out whatever is left. if humans == 1 { for i := range s.Seats { if !s.Seats[i].Bot { s.Phase = PhaseDone s.Payout = s.Seats[i].Stack *evs = append(*evs, Event{Kind: MoveLeave, Seat: i, N: int(s.Payout)}) return } } } s.Phase = PhaseHandOver // degenerate; nothing to deal return } s.HandNo++ s.Dir = 1 deck := s.Tier.Deck() rng := stepRNG(s.Seed1, s.Seed2, s.Step) rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) s.Deck = deck // Ante up, then deal. for _, seat := range in { p := &s.Seats[seat] p.Stack -= s.Tier.Ante p.Ante = s.Tier.Ante s.Pot += s.Tier.Ante *evs = append(*evs, Event{Kind: EvAnte, Seat: seat, N: int(s.Tier.Ante)}) } for i := range s.Hands { s.Hands[i] = make([]Card, 0, HandSize) } for c := 0; c < HandSize; c++ { for _, seat := range in { 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 { break // 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 } s.Phase = PhasePlay s.Turn = s.Dealer s.advance(1) // the seat after the dealer acts first *evs = append(*evs, s.mine(Event{Kind: EvDeal, Seat: s.Turn, Card: s.topPtr(), Color: s.Color})) } // seatPlays puts one of a seat's cards on the pile. func (s *State) seatPlays(seat int, m Move, rng *rand.Rand) ([]Event, error) { hand := s.Hands[seat] 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. if s.Phase == PhaseDrawn && m.Index != len(hand)-1 { return nil, ErrMustPlayNow } card := hand[m.Index] // With a stack pointed at you, the only cards that exist are the ones that // answer it. Everything else is unplayable until the bill is settled. if s.Phase == PhaseStack { if !card.CanStackOn(s.Color) { return nil, ErrMustStack } } else if !card.CanPlayOn(s.top(), s.Color) { return nil, ErrCantPlay } if card.IsWild() && !m.Color.Playable() { return nil, ErrNeedColor } s.Hands[seat] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...) var evs []Event s.discard(seat, card, m.Color, &evs) s.after(seat, card, &evs, rng) s.declare(seat, m.Uno, &evs) return evs, nil } // seatDraws takes cards off the deck. The normal game takes one; No Mercy makes // you draw until you can play. See the long-form note that used to live here — the // rules are the same, only the seat is a parameter now. func (s *State) seatDraws(seat int, rng *rand.Rand) ([]Event, error) { if s.Phase == PhaseDrawn { return nil, ErrMustPlayNow } if s.Phase == PhaseStack { return nil, ErrMustStack } var evs []Event if !s.Tier.NoMercy { drawn := s.deal(seat, 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: seat}) s.advance(1) return evs, nil } for { drawn := s.deal(seat, 1, false, &evs, rng) if len(drawn) == 0 { break // the table has nothing left to draw } if s.mercy(seat, &evs, rng) { return evs, nil // twenty-five cards, and this seat is out of the hand } if drawn[0].CanPlayOn(s.top(), s.Color) { s.Phase = PhaseDrawn return evs, nil } } evs = append(evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) return evs, nil } // seatTakes gives in to a stack: the seat takes every card it has run up, and // loses its turn. func (s *State) seatTakes(seat int, rng *rand.Rand) ([]Event, error) { if s.Phase != PhaseStack { return nil, ErrNoStack } var evs []Event s.absorb(seat, &evs, rng) return evs, nil } // seatPasses declines the card the seat just drew. In No Mercy you may not — the // card you drew is the price of having drawn. func (s *State) seatPasses(seat int) ([]Event, error) { if s.Phase != PhaseDrawn { return nil, ErrCantPass } if s.Tier.NoMercy { return nil, ErrMustPlayNow } s.Phase = PhasePlay s.advance(1) return []Event{{Kind: EvPass, Seat: seat}}, nil } // runBots plays every bot turn up to the next human's decision. It stops the // moment the hand is over, the turn lands on a human, or the table dies under it. func (s *State) runBots(evs *[]Event, rng *rand.Rand) { for s.playing() && s.Seats[s.Turn].Bot && !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) { // A stack pointed at this bot is not a turn, it is a bill. It answers with a // draw card if it holds one, and takes the lot if it doesn't. if s.Phase == PhaseStack { card, idx := botStack(s.Hands[seat], s.Color, rng) if idx < 0 { s.absorb(seat, evs, rng) return } s.botPlays(seat, card, idx, evs, rng) return } card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng) if idx < 0 { for { drawn := s.deal(seat, 1, false, evs, rng) if len(drawn) != 1 { *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) return } if s.Tier.NoMercy && s.mercy(seat, evs, rng) { return } if drawn[0].CanPlayOn(s.top(), s.Color) { card, idx = drawn[0], len(s.Hands[seat])-1 break } if !s.Tier.NoMercy { *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) s.advance(1) return } } } s.botPlays(seat, card, idx, evs, rng) } // botPlays puts a bot's chosen card down and resolves it. func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.Rand) { 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) if card.Value == WildRoulette { color = botRouletteColor(s.Hands[seat], rng) } } s.discard(seat, card, color, evs) s.after(seat, card, evs, rng) s.declare(seat, !botForgets(rng), evs) } // stalled reports whether the table is dead: nothing left to draw anywhere, and // not one seat holding a card that goes on the pile. func (s State) stalled() bool { if s.Pending > 0 { return false // a stack is a move somebody still has to make: taking it } 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 _, seat := range s.alive() { for _, c := range s.Hands[seat] { 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. 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, s.mine(e)) } // after resolves what the card just played does, and moves the turn on. func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { if len(s.Hands[seat]) == 0 { s.settle(seat, OutcomeWon, evs) return } s.Phase = PhasePlay if n := card.Value.Draw(); n > 0 { if card.Value == WildRevFour { s.flip(seat, evs) } if s.Tier.NoMercy { s.Pending += n s.advance(1) s.Phase = PhaseStack *evs = append(*evs, Event{Kind: EvStack, Seat: s.Turn, N: s.Pending}) return } s.punish(s.seatAt(1), n, evs, rng) return } switch card.Value { case Skip: victim := s.seatAt(1) *evs = append(*evs, Event{Kind: EvSkip, Seat: victim}) s.advance(2) case Reverse: s.flip(seat, evs) case SkipAll: *evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat}) case DiscardAll: s.discardAll(seat, card.Color, evs) if len(s.Hands[seat]) == 0 { s.settle(seat, OutcomeWon, evs) return } s.advance(1) case WildRoulette: s.roulette(s.seatAt(1), s.Color, evs, rng) default: s.advance(1) } } // flip turns the direction round — or, at a table of two, skips the only other // player, because a reverse with nobody to hand the turn back to is a card that // means you go again. func (s *State) flip(seat int, evs *[]Event) { if len(s.alive()) == 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) } // punish makes the next seat eat a draw card and lose its turn — the normal // game's rule (no stacking). 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. Every card carries its face on the event; the web layer redacts the // faces the viewer isn't entitled to. func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card { got := s.drawCards(seat, n, evs, rng) 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 len(got) == 1 { c := got[0] e.Card = &c // one card drawn comes with its face; the web layer redacts it per viewer } *evs = append(*evs, s.mine(e)) return got } // drawCards is deal without the announcement. func (s *State) drawCards(seat, n int, 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) } return got } // reshuffle turns the discard back into a deck, keeping the card in play on top. func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool { if len(s.Discard) < 2 { return false } 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] }) 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 a hand: the winner takes the pot, less the house's rake if they are // a human (a bot winning is the house keeping the pot, so there is nothing to // rake). The rake, as everywhere in this casino, comes out of the winnings and // never out of the stake. The table returns to PhaseHandOver, ready to deal again. func (s *State) settle(winner int, outcome Outcome, evs *[]Event) { rake := int64(0) if !s.Seats[winner].Bot { profit := s.Pot - s.Seats[winner].Ante rake = s.rakeOn(profit) s.Paid += rake } net := s.Pot - rake s.Seats[winner].Stack += net s.Seats[winner].Won = net s.Winner = winner s.LastPot = s.Pot s.Rake = rake s.Outcome = outcome s.Pot = 0 s.Phase = PhaseHandOver *evs = append(*evs, Event{Kind: EvSettle, Seat: winner, N: int(net), Text: string(outcome)}) } // refund hands every seat its ante back and ends the hand a draw. It is what a // stuck-and-level table does: nobody went out, and nobody is a length shorter // than everybody else, so there is no winner to take the pot. func (s *State) refund(evs *[]Event) { for i := range s.Seats { if s.Seats[i].Ante > 0 { s.Seats[i].Stack += s.Seats[i].Ante } } s.Winner = -1 s.LastPot = 0 s.Rake = 0 s.Outcome = OutcomeTie s.Pot = 0 s.Phase = PhaseHandOver *evs = append(*evs, Event{Kind: EvSettle, Seat: -1, Text: string(OutcomeTie)}) } // stuck ends a hand nobody can move in: the deck is spent, the discard is one card // deep, and every seat has passed. The shortest hand takes the pot; a tie refunds // the antes, because a win here has to be somebody actually being ahead. func (s *State) stuck(evs *[]Event) { live := s.alive() if len(live) == 0 { s.refund(evs) // can't happen: a mercy kill that empties the table settles first return } best, tied := live[0], false for _, seat := range live { 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 } } if tied { s.refund(evs) return } s.settle(best, OutcomeStuck, evs) } func (s State) rakeOn(profit int64) int64 { if profit <= 0 { return 0 } rake := int64(math.Floor(float64(profit) * s.RakePct)) if rake < 0 { return 0 } return rake } // Playable reports which cards of a seat's hand can legally go on the pile. The // browser lights these up for the seat that is looking; the server still decides // every move regardless. func (s State) Playable(seat int) []int { if !s.playing() || s.Turn != seat { return nil } hand := s.Hands[seat] if s.Phase == PhaseDrawn { if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) { return []int{len(hand) - 1} } return nil } if s.Phase == PhaseStack { var out []int for i, c := range hand { if c.CanStackOn(s.Color) { out = append(out, i) } } return out } 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 // other seats' 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 *live* places round from the one whose turn it is. A seat // not in the hand is stepped over, not landed on and skipped. func (s State) seatAt(n int) int { seats := len(s.Seats) at := s.Turn for moved := 0; moved < n; { at = ((at+s.Dir)%seats + seats) % seats if at == s.Turn && !s.live(at) { return at // nobody left alive to hand it to; the caller ends the hand } if s.live(at) { moved++ } } return at } // 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. func (s State) minOpponent(seat int) int { min := -1 for i := range s.Hands { if i == seat || !s.live(i) { 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.Seats = append([]Seat(nil), s.Seats...) s.Deck = append([]Card(nil), s.Deck...) s.Discard = append([]Card(nil), s.Discard...) s.Out = append([]bool(nil), s.Out...) s.Called = append([]bool(nil), s.Called...) return s } // stepRNG is the generator for one step of the game. func stepRNG(seed1, seed2, step uint64) *rand.Rand { return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15))) }