Files
Pete/internal/games/uno/uno.go
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.

The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.

The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:37:51 -07:00

1321 lines
42 KiB
Go

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