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
1321 lines
42 KiB
Go
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)))
|
|
}
|