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
1118 lines
36 KiB
Go
1118 lines
36 KiB
Go
// Package uno is a pure UNO engine, played for chips against bots.
|
||
//
|
||
// Same seam as the other four tables: ApplyMove(state, move) (state, events,
|
||
// error), where an error means the move was illegal and nothing else. No HTTP,
|
||
// no timers, no sockets, no player names off the wire. The state is a plain
|
||
// value, so a game survives a redeploy and replays from its seed.
|
||
//
|
||
// Two things make UNO different from the tables already on the felt.
|
||
//
|
||
// The bots move inside ApplyMove. A turn-based game against opponents is
|
||
// normally where you reach for a socket, and the plan says solo UNO must not:
|
||
// so one call from the browser plays the player's move *and* every bot turn that
|
||
// follows it, and hands back the whole run as events. The table animates them in
|
||
// order. The browser is never waiting on the server to think of something.
|
||
//
|
||
// The RNG is in the state, not an argument. The bots make choices and a spent
|
||
// deck gets reshuffled, so the engine needs randomness mid-game — but a reducer
|
||
// that takes an rng is a reducer whose caller has to keep one alive across
|
||
// requests, and there isn't one: every move is a fresh process for all it knows.
|
||
// So the seed rides in the state (which never leaves the server; the deck is in
|
||
// there too) and each step derives its own generator from seed and step count.
|
||
// Value in, value out, and the game still replays exactly as it was dealt.
|
||
package uno
|
||
|
||
import (
|
||
"errors"
|
||
"math"
|
||
"math/rand/v2"
|
||
)
|
||
|
||
// Errors an illegal move can produce.
|
||
var (
|
||
ErrGameOver = errors.New("uno: the game is already over")
|
||
ErrNotYourTurn = errors.New("uno: it isn't your turn")
|
||
ErrNoSuchCard = errors.New("uno: you don't have that card")
|
||
ErrCantPlay = errors.New("uno: that card can't go on this one")
|
||
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
||
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
|
||
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
|
||
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 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.
|
||
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)))
|
||
}
|