// 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") 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") 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 // 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 — 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. // 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"` Base float64 `json:"base"` // what going out first pays, before the rake Blurb string `json:"blurb"` NoMercy bool `json:"no_mercy"` } // Deck is the deck this tier plays with. func (t Tier) Deck() []Card { if t.NoMercy { return NewNoMercyDeck() } return NewDeck() } // 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. // Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% / // 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots // got better at some point after the multiples were set and nobody re-ran the // measurement, so Table and Full House had quietly been charging an 18–19% edge // instead of the 8% they were meant to. The numbers below are the honest ones. // // This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop. // // Re-measured again when the UNO call went in (call.go): 40.1% / 28.5% / 23.1%, // which is the same game. That is the point — the naive player *calls*, because // calling is a button and not a strategy, so the rule costs them nothing and // these multiples stand. What the rule adds is upside for a player who watches // the counts and catches a quiet bot, which is the good play these tiers are // meant to leave room for. var Tiers = []Tier{ {Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4, Blurb: "One bot, head to head. A reverse is a skip with two at the table."}, {Slug: "table", Name: "Table", Bots: 2, Base: 3.3, Blurb: "Two bots. Twice the +4s pointed at you."}, {Slug: "full", Name: "Full House", Bots: 3, Base: 4.1, Blurb: "Three bots, and any of them going out first takes your stake."}, } // NoMercyTiers are the same three tables playing the other rules. // // The multiples are measured, not guessed, and they are *not* the normal ones — // the naive strategy (play the first legal card; take a stack you can't answer) // wins 45.6% / 31.8% / 27.4% here, against 40.1 / 28.5 / 23.1 on the normal deck. // // Which is to say: **No Mercy is easier than UNO**, at every table size, and so // it pays less. That reads backwards until you see why. The mercy rule kills // *bots* — it does not care whose hand hits twenty-five — and every bot it buries // is one fewer seat that can beat you to the last card. Three opponents burying // each other is a game you win by outliving, and the deck that was built to be // merciless turns out to be merciless mostly to the table. // // So a nastier game pays a smaller multiple, which is the correct answer and a // slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest: // change the bots, the deck or a rule, and it fails until these are measured // again. It is the test the normal tiers never had, which is how they drifted. var NoMercyTiers = []Tier{ {Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true, Blurb: "One bot, 168 cards. Stack the draws or eat them."}, {Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true, Blurb: "Two bots. A +10 answered twice is somebody's whole hand."}, // Re-priced 2026-07-14 with the call rule in: 3.8 was paying a *negative* house // edge here (-0.2%), which is the house paying you to sit down. The naive win // rate at this table is 27.4%, not the 25.3% it was priced off — three bots // burying each other is even better for you than the last measurement caught. {Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.5, NoMercy: true, Blurb: "Three bots. Twenty-five cards and you're out of the game."}, } // 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 ( 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" ) // 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 // No Mercy only. Out is the seats the mercy rule has killed, and it is what // the turn order steps over — a dead seat is skipped, not merely empty. // 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"` 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 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 *your* hand as it stands after this event, and it is only ever set // on an event that changed it. The table plays a lap of events back over // several seconds, and for all of them the hand on screen has to be the hand // you actually hold — a +4 that lands on you at the top of the lap must show // up in your fan as it lands, not when the lap ends. There is no leak here: // this is the one hand the browser is already entitled to see. Hand []Card `json:"hand,omitempty"` } // mine stamps the player's hand onto an event that just changed it. Events about // a bot's hand carry nothing — the browser never learns those, and stamping is // scoped to seat zero precisely so it can't start. func (s *State) mine(e Event) Event { if e.Seat == You { e.Hand = append([]Card(nil), s.Hands[You]...) } 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 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, and its owner said so // caught a seat went down to one card *quietly*, and somebody noticed: +2 // miscall you called a seat that had nothing to hide, and paid for it: +2 // reshuffle the discard goes back under // settle it's 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 game const ( EvDeal = "deal" EvPlay = "play" EvDraw = "draw" EvForced = "forced" EvPass = "pass" EvSkip = "skip" EvReverse = "reverse" EvUno = "uno" EvCaught = "caught" EvMiscall = "miscall" EvReshuffle = "reshuffle" EvSettle = "settle" EvStack = "stack" EvSkipAll = "skipall" EvDiscardAll = "discard" EvRoulette = "roulette" EvMercy = "mercy" ) // 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" | "take" | "catch" 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. It is a move you make // out of turn order (it costs you nothing but the risk of being wrong), so it is // a kind rather than a flag on the moves that do cost a turn. const ( MovePlay = "play" MoveDraw = "draw" MovePass = "pass" MoveTake = "take" MoveCatch = "catch" ) // 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 := t.Deck() 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.Out = make([]bool, seats) s.Called = make([]bool, seats) 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{s.mine(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++ next.ensureCalled() 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() case MoveTake: evs, err = next.playerTakes(rng) case MoveCatch: evs, err = next.playerCatches(m, rng) default: return s, nil, ErrUnknownMove } if err != nil { return s, nil, err // the caller's state, untouched } // Before anybody moves: did you 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 at your hand. next.botsCatch(&evs, rng) // 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. // // A catch is not a turn — it leaves the turn where it was, which is with you — // so this is a no-op after one, and that is the whole mechanism by which // catching a bot costs you nothing but the risk of being wrong. 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) } next.tidyCalls() 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] // With a stack pointed at you, the only cards that exist are the ones that // answer it. Everything else in your hand is unplayable until the bill is // settled — by you, or by the seat you pass it to. 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[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) // Whether you called is checked *after* the card has finished resolving, not // after it left your hand. No Mercy's "discard all" takes every card of a // colour with it, so it can drop you from six cards to one in a single play — // and the seat that gets there that way owes the call just the same. s.declare(You, m.Uno, &evs) return evs, nil } // playerDraws takes cards off the deck. // // The normal game takes one: if it can be played you get the choice — that's // PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn // passes on the spot, because there is nothing to decide. // // No Mercy makes you draw *until* you can play. There is no drawing one card and // shrugging, which is most of why hands there get big enough for the mercy rule // to have something to kill. The card you end on is a card you must then play, so // there is still nothing to decide — but the deck can be dry, and a hand can hit // twenty-five on the way, and both of those end the drawing. func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) { if s.Phase == PhaseDrawn { return nil, ErrMustPlayNow // you already drew; play it or pass } if s.Phase == PhaseStack { return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack } var evs []Event if !s.Tier.NoMercy { 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 } for { drawn := s.deal(You, 1, false, &evs, rng) if len(drawn) == 0 { break // the table has nothing left to draw } if s.mercy(You, &evs, rng) { return evs, nil // twenty-five cards, and you are out of the game } if 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 } // playerTakes gives in to a stack: you take every card it has run up, and you // lose your turn. func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) { if s.Phase != PhaseStack { return nil, ErrNoStack } var evs []Event s.absorb(You, &evs, rng) return evs, nil } // playerPasses declines the card you just drew. // // In No Mercy you may not: you drew until you found a card that plays, and that // card is the price of having drawn. Passing there would make drawing a way to // buy a look at the deck and put nothing down. func (s *State) playerPasses() ([]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: 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) { // 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 { // Nothing playable: draw. The normal game draws one and shrugs; No Mercy // draws until something goes, which is what buries a bot as surely as it // buries you — the mercy rule cuts both ways, and a bot can die on the deck. 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 { // The roulette is not a card you play a colour *from*, it is a card you // point at somebody. So the bot names the colour it holds least of, which // is the one the deck is least likely to turn up quickly. color = botRouletteColor(s.Hands[seat], rng) } } s.discard(seat, card, color, evs) s.after(seat, card, evs, rng) // A bot that has just gone down to one card mostly remembers to say so. When it // doesn't, it says nothing at all — no event, no badge — and the only thing on // the table that gives it away is the count beside its fan reading "1 card". // Spotting that is the player's to do. 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. // // 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 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. // // 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, s.mine(e)) // No UNO event here any more. Going down to one card used to *be* the call, // which meant nobody at this table could ever fail to make it. Now the call is // a thing a seat does or doesn't do, and it is announced — or conspicuously not // — by the two functions that know whether it was made. See call.go. } // 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 // A draw card. In No Mercy this doesn't land yet: it opens a stack, and the // seat it points at gets the choice of answering it. In the normal game it // lands where it always did. if n := card.Value.Draw(); n > 0 { if card.Value == WildRevFour { s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that } 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: // Everyone else loses their turn, which means it comes straight back to the // seat that played it. The turn does not move at all. *evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat}) case DiscardAll: // Every other card of this colour goes down with it. That can empty the // hand, which is a win — and the reason this can't lean on the empty-hand // check at the top of the function, which already ran. s.discardAll(seat, card.Color, evs) if len(s.Hands[seat]) == 0 { s.settle(seat, 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. This is the // normal game's rule: no stacking, because a +2 played onto a +2 is a house rule // and the one on the box is the one this deck plays. No Mercy prints the stacking // rule on its own box, and takes the other road out of after(). 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 := 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 seat == You && len(got) == 1 { c := got[0] e.Card = &c // your own card, and only yours, comes face up } *evs = append(*evs, s.mine(e)) return got } // drawCards is deal without the announcement: cards come off the deck (with a // reshuffle under them if it runs dry) and go into a hand, and nothing is said // about it. deal wraps it to say the usual thing; a catch wraps it to say a // different thing. It hands back what was actually drawn, which can be fewer // cards than asked for when the table has nothing left to give. 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 // 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) { live := s.alive() if len(live) == 0 { s.lose(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 } } 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 } // Under a stack, the only cards that light up are the ones that answer it. // Everything else in the hand is dead until the bill is paid. 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 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 *live* places round from the one whose turn it is. // // A seat the mercy rule has killed is not there any more: it is stepped over, not // landed on and skipped. So this counts living seats rather than doing the // arithmetic on the index — which is the same thing in a normal game, where // nobody is ever out, and the only thing that keeps a No Mercy table from // handing the turn to a corpse. func (s State) seatAt(n int) int { seats := len(s.Hands) 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 game } 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 — 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...) 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. 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))) }