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