Files
Pete/internal/games/uno/uno.go
prosolis aca523e511 games: no mercy, and the multiples nobody re-measured
No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards,
draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six
tiers now; a normal game never runs a line of the new code.

The engine is the whole of it so far — the felt hasn't been touched, so there is
no way to play this in a browser yet.

Two things worth knowing.

The normal tiers were mispriced, and had been for a while. They were set against
a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got
better at some point after the multiples were written down and nobody re-ran the
measurement — which the plan explicitly warns about, because the bots and the
tiers are a pair. Table and Full House had been charging an 18–19% house edge
instead of the 8% they were meant to. All six tiers are repriced off a fresh
measurement, and TestTheMultiplesAreStillPriced now fails the build if they
drift again. It is the test the normal tiers never had, which is how they drifted.

And No Mercy is *easier* than UNO, at every table size, so it pays less. The
mercy rule does not care whose hand hits twenty-five: it kills bots too, and
every bot it buries is one fewer seat that can beat you to the last card. A deck
built to be merciless turns out to be merciless mostly to the table.

The rake test used to assert a payout of 214, which was the 2.2x duel written
down as a number. It failed on a rake that was entirely correct. It derives the
arithmetic from the tier now: the rule is that the house takes its cut of the
profit and never touches the stake, and that holds at any multiple.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00

1118 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
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 1819% 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.
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 46.7% / 31.2% / 25.3% here, against 40.3 / 29.2 / 23.3 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."},
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.8, 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"`
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates. The bots' turns arrive as a run of
// these on the back of the player's own move, and the felt plays them in order.
type Event struct {
Kind string `json:"kind"` // see below
Seat int `json:"seat"` // who it happened to
Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
N int `json:"n,omitempty"` // how many cards were drawn
Left int `json:"left"` // cards left in that seat's hand afterwards
Text string `json:"text,omitempty"`
}
// The kinds an Event comes in.
//
// deal the hands are dealt and the first card turned over
// play a card goes on the pile
// wild the colour was named (rides with the play it belongs to)
// draw cards come off the deck. A bot's are face down: Card is nil.
// forced the same, but not by choice — a +2 or a +4 landed on them
// pass the turn moves on with nothing played
// skip a seat loses its turn
// reverse the direction flips
// uno a hand is down to one card
// reshuffle the discard goes back under
// settle it's over
//
// 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"
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"
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. 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.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
)
// 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.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()
case MoveTake:
evs, err = next.playerTakes(rng)
default:
return s, nil, ErrUnknownMove
}
if err != nil {
return s, nil, err // the caller's state, untouched
}
// The bots take their turns on the back of yours, and the whole run comes
// back as one script. This is the reason solo UNO needs no socket.
next.runBots(&evs, rng)
// And if that left a table nobody can move at, it ends here rather than
// handing back a turn that has nothing in it. See stalled().
if next.Phase != PhaseDone && next.stalled() {
next.stuck(&evs)
}
return next, evs, nil
}
// playerPlays puts one of your cards on the pile.
func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
hand := s.Hands[You]
if m.Index < 0 || m.Index >= len(hand) {
return nil, ErrNoSuchCard
}
// Having drawn a playable card, the only card you may play is that one. Being
// allowed to draw and *then* play something else would make drawing a free
// look at the deck with no cost attached.
if s.Phase == PhaseDrawn && m.Index != len(hand)-1 {
return nil, ErrMustPlayNow
}
card := hand[m.Index]
// 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)
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)
}
// 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, 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
// 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 := 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) {
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...)
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)))
}