// 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))) }