games: no mercy, and the multiples nobody re-measured

No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards,
draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six
tiers now; a normal game never runs a line of the new code.

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

Two things worth knowing.

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

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

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

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 10:07:55 -07:00
parent 4bc38859d4
commit aca523e511
5 changed files with 1092 additions and 61 deletions

View File

@@ -37,6 +37,8 @@ var (
ErrNeedColor = errors.New("uno: pick a colour for the wild")
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it")
ErrNoStack = errors.New("uno: there's no stack to take")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
@@ -80,6 +82,10 @@ 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
@@ -96,13 +102,23 @@ const (
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 = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"skip", "reverse", "+2", "wild", "+4"}
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 > WildDrawFour {
if v > WildRoulette {
return "?"
}
return valueNames[v]
@@ -111,6 +127,35 @@ func (v Value) String() string {
// 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 {
@@ -119,7 +164,7 @@ type Card struct {
}
// IsWild reports whether the card has no colour of its own.
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
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
@@ -153,12 +198,24 @@ func NewDeck() []Card {
// shot — three of them going out before you is three ways to lose — so it pays
// more. This is the tier dial every other game here has, pointed at the one knob
// UNO actually has.
// No Mercy rides on the same struct rather than a second one, because it is the
// tier that lands in the state and the payload — so a game carries which rules it
// is playing by, and cannot be reloaded into the other set.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Bots int `json:"bots"`
Base float64 `json:"base"` // what going out first pays, before the rake
Blurb string `json:"blurb"`
Slug string `json:"slug"`
Name string `json:"name"`
Bots int `json:"bots"`
Base float64 `json:"base"` // what going out first pays, before the rake
Blurb string `json:"blurb"`
NoMercy bool `json:"no_mercy"`
}
// Deck is the deck this tier plays with.
func (t Tier) Deck() []Card {
if t.NoMercy {
return NewNoMercyDeck()
}
return NewDeck()
}
// Tiers are the three tables.
@@ -169,18 +226,56 @@ type Tier struct {
// under what that costs, so bad play loses slowly and good play (holding the
// wilds, dumping the colour you're long in, counting what a bot picked up) is
// worth roughly the house's edge. That is the game being about something.
// Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% /
// 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots
// got better at some point after the multiples were set and nobody re-ran the
// measurement, so Table and Full House had quietly been charging an 1819% edge
// instead of the 8% they were meant to. The numbers below are the honest ones.
//
// This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop.
var Tiers = []Tier{
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4,
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
{Slug: "table", Name: "Table", Bots: 2, Base: 3.3,
Blurb: "Two bots. Twice the +4s pointed at you."},
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
{Slug: "full", Name: "Full House", Bots: 3, Base: 4.1,
Blurb: "Three bots, and any of them going out first takes your stake."},
}
// TierBySlug finds a tier by the name the browser sent.
// NoMercyTiers are the same three tables playing the other rules.
//
// The multiples are measured, not guessed, and they are *not* the normal ones —
// the naive strategy (play the first legal card; take a stack you can't answer)
// wins 46.7% / 31.2% / 25.3% here, against 40.3 / 29.2 / 23.3 on the normal deck.
//
// Which is to say: **No Mercy is easier than UNO**, at every table size, and so
// it pays less. That reads backwards until you see why. The mercy rule kills
// *bots* — it does not care whose hand hits twenty-five — and every bot it buries
// is one fewer seat that can beat you to the last card. Three opponents burying
// each other is a game you win by outliving, and the deck that was built to be
// merciless turns out to be merciless mostly to the table.
//
// So a nastier game pays a smaller multiple, which is the correct answer and a
// slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest:
// change the bots, the deck or a rule, and it fails until these are measured
// again. It is the test the normal tiers never had, which is how they drifted.
var NoMercyTiers = []Tier{
{Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true,
Blurb: "One bot, 168 cards. Stack the draws or eat them."},
{Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true,
Blurb: "Two bots. A +10 answered twice is somebody's whole hand."},
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.8, NoMercy: true,
Blurb: "Three bots. Twenty-five cards and you're out of the game."},
}
// AllTiers is every table in the room, both dials.
func AllTiers() []Tier {
return append(append([]Tier(nil), Tiers...), NoMercyTiers...)
}
// TierBySlug finds a tier by the name the browser sent, across both rule sets.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
for _, t := range AllTiers() {
if t.Slug == slug {
return t, nil
}
@@ -194,6 +289,7 @@ type Phase string
const (
PhasePlay Phase = "play" // your turn, play or draw
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it
PhaseDone Phase = "done"
)
@@ -222,6 +318,13 @@ type State struct {
Turn int `json:"turn"`
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
// No Mercy only. Out is the seats the mercy rule has killed, and it is what
// the turn order steps over — a dead seat is skipped, not merely empty.
// Pending is the bill a stack of draw cards has run up: whoever stops
// stacking pays it.
Out []bool `json:"out,omitempty"`
Pending int `json:"pending,omitempty"`
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
@@ -259,6 +362,14 @@ type Event struct {
// uno a hand is down to one card
// reshuffle the discard goes back under
// settle it's over
//
// And the No Mercy ones:
//
// stack a draw card is pointed at a seat: N is the bill so far
// skipall everybody else loses their turn
// discard a whole colour left a hand at once
// roulette a seat flipped N cards looking for a colour, and kept them
// mercy a seat hit 25 cards and is out of the game
const (
EvDeal = "deal"
EvPlay = "play"
@@ -270,6 +381,12 @@ const (
EvUno = "uno"
EvReshuffle = "reshuffle"
EvSettle = "settle"
EvStack = "stack"
EvSkipAll = "skipall"
EvDiscardAll = "discard"
EvRoulette = "roulette"
EvMercy = "mercy"
)
// Move is what the player sends: play this card, take one off the deck, or —
@@ -280,11 +397,14 @@ type Move struct {
Color Color `json:"color"` // the colour you name, for a wild
}
// Move kinds.
// Move kinds. Take is No Mercy's: it is how you give in to a stack you can't
// answer, and it is a *decision*, so it gets a name of its own rather than being
// bolted onto draw.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
@@ -302,7 +422,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
}
rng := stepRNG(seed1, seed2, 0)
deck := NewDeck()
deck := t.Deck()
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
s := State{
@@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
}
seats := t.Bots + 1
s.Out = make([]bool, seats)
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
@@ -369,6 +490,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
evs, err = next.playerDraws(rng)
case MovePass:
evs, err = next.playerPasses()
case MoveTake:
evs, err = next.playerTakes(rng)
default:
return s, nil, ErrUnknownMove
}
@@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
return nil, ErrMustPlayNow
}
card := hand[m.Index]
if !card.CanPlayOn(s.top(), s.Color) {
// With a stack pointed at you, the only cards that exist are the ones that
// answer it. Everything else in your hand is unplayable until the bill is
// settled — by you, or by the seat you pass it to.
if s.Phase == PhaseStack {
if !card.CanStackOn(s.Color) {
return nil, ErrMustStack
}
} else if !card.CanPlayOn(s.top(), s.Color) {
return nil, ErrCantPlay
}
if card.IsWild() && !m.Color.Playable() {
@@ -415,29 +546,78 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
return evs, nil
}
// playerDraws takes one off the deck. If it can be played you get the choice —
// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it
// can't, the turn passes on the spot: there is nothing to decide.
// playerDraws takes cards off the deck.
//
// The normal game takes one: if it can be played you get the choice — that's
// PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn
// passes on the spot, because there is nothing to decide.
//
// No Mercy makes you draw *until* you can play. There is no drawing one card and
// shrugging, which is most of why hands there get big enough for the mercy rule
// to have something to kill. The card you end on is a card you must then play, so
// there is still nothing to decide — but the deck can be dry, and a hand can hit
// twenty-five on the way, and both of those end the drawing.
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
if s.Phase == PhaseDrawn {
return nil, ErrMustPlayNow // you already drew; play it or pass
}
if s.Phase == PhaseStack {
return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack
}
var evs []Event
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
if !s.Tier.NoMercy {
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
return evs, nil
}
evs = append(evs, Event{Kind: EvPass, Seat: You})
s.advance(1)
return evs, nil
}
for {
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 0 {
break // the table has nothing left to draw
}
if s.mercy(You, &evs, rng) {
return evs, nil // twenty-five cards, and you are out of the game
}
if drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
return evs, nil
}
}
evs = append(evs, Event{Kind: EvPass, Seat: You})
s.advance(1)
return evs, nil
}
// playerTakes gives in to a stack: you take every card it has run up, and you
// lose your turn.
func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) {
if s.Phase != PhaseStack {
return nil, ErrNoStack
}
var evs []Event
s.absorb(You, &evs, rng)
return evs, nil
}
// playerPasses declines the card you just drew.
//
// In No Mercy you may not: you drew until you found a card that plays, and that
// card is the price of having drawn. Passing there would make drawing a way to
// buy a look at the deck and put nothing down.
func (s *State) playerPasses() ([]Event, error) {
if s.Phase != PhaseDrawn {
return nil, ErrCantPass
}
if s.Tier.NoMercy {
return nil, ErrMustPlayNow
}
s.Phase = PhasePlay
s.advance(1)
return []Event{{Kind: EvPass, Seat: You}}, nil
@@ -455,24 +635,61 @@ func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
// botTurn plays one bot's turn.
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
if idx < 0 {
// Nothing playable: draw one, and play it if it happens to go.
drawn := s.deal(seat, 1, false, evs, rng)
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
// 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
}
card, idx = drawn[0], len(s.Hands[seat])-1
s.botPlays(seat, card, idx, evs, rng)
return
}
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
if idx < 0 {
// Nothing playable: draw. The normal game draws one and shrugs; No Mercy
// draws until something goes, which is what buries a bot as surely as it
// buries you — the mercy rule cuts both ways, and a bot can die on the deck.
for {
drawn := s.deal(seat, 1, false, evs, rng)
if len(drawn) != 1 {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return
}
if s.Tier.NoMercy && s.mercy(seat, evs, rng) {
return
}
if drawn[0].CanPlayOn(s.top(), s.Color) {
card, idx = drawn[0], len(s.Hands[seat])-1
break
}
if !s.Tier.NoMercy {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return
}
}
}
s.botPlays(seat, card, idx, evs, rng)
}
// botPlays puts a bot's chosen card down and resolves it.
func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.Rand) {
hand := s.Hands[seat]
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
color := card.Color
if card.IsWild() {
color = botColor(s.Hands[seat], rng)
if card.Value == WildRoulette {
// The roulette is not a card you play a colour *from*, it is a card you
// point at somebody. So the bot names the colour it holds least of, which
// is the one the deck is least likely to turn up quickly.
color = botRouletteColor(s.Hands[seat], rng)
}
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
@@ -489,11 +706,14 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
// and worse than either, a live game you can't finish is chips you can't cash
// out, because the cage won't let you leave a hand half-played.
func (s State) stalled() bool {
if s.Pending > 0 {
return false // a stack is a move somebody still has to make: taking it
}
if len(s.Deck) > 0 || len(s.Discard) > 1 {
return false // there is a card to draw, or a discard to make one out of
}
for _, hand := range s.Hands {
for _, c := range hand {
for _, seat := range s.alive() {
for _, c := range s.Hands[seat] {
if c.CanPlayOn(s.top(), s.Color) {
return false
}
@@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
}
s.Phase = PhasePlay
// A draw card. In No Mercy this doesn't land yet: it opens a stack, and the
// seat it points at gets the choice of answering it. In the normal game it
// lands where it always did.
if n := card.Value.Draw(); n > 0 {
if card.Value == WildRevFour {
s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that
}
if s.Tier.NoMercy {
s.Pending += n
s.advance(1)
s.Phase = PhaseStack
*evs = append(*evs, Event{Kind: EvStack, Seat: s.Turn, N: s.Pending})
return
}
s.punish(s.seatAt(1), n, evs, rng)
return
}
switch card.Value {
case Skip:
victim := s.seatAt(1)
@@ -539,31 +777,50 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
s.advance(2)
case Reverse:
// Two at the table and a reverse has nobody to hand the turn back to, so it
// is a skip — which, with two players, means you go again.
if len(s.Hands) == 2 {
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
s.advance(2)
s.flip(seat, evs)
case SkipAll:
// Everyone else loses their turn, which means it comes straight back to the
// seat that played it. The turn does not move at all.
*evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat})
case DiscardAll:
// Every other card of this colour goes down with it. That can empty the
// hand, which is a win — and the reason this can't lean on the empty-hand
// check at the top of the function, which already ran.
s.discardAll(seat, card.Color, evs)
if len(s.Hands[seat]) == 0 {
s.settle(seat, evs)
return
}
s.Dir = -s.Dir
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
s.advance(1)
case DrawTwo:
s.punish(s.seatAt(1), 2, evs, rng)
case WildDrawFour:
s.punish(s.seatAt(1), 4, evs, rng)
case WildRoulette:
s.roulette(s.seatAt(1), s.Color, evs, rng)
default:
s.advance(1)
}
}
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
// +2 played onto a +2 is a house rule, and the one this table plays is the one
// on the box.
// flip turns the direction round — or, at a table of two, skips the only other
// player, because a reverse with nobody to hand the turn back to is a card that
// means you go again.
func (s *State) flip(seat int, evs *[]Event) {
if len(s.alive()) == 2 {
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
s.advance(2)
return
}
s.Dir = -s.Dir
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
s.advance(1)
}
// punish makes the next seat eat a draw card and lose its turn. This is the
// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule
// and the one on the box is the one this deck plays. No Mercy prints the stacking
// rule on its own box, and takes the other road out of after().
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
s.deal(victim, n, true, evs, rng)
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
@@ -648,8 +905,13 @@ func (s *State) settle(winner int, evs *[]Event) {
// card deep, and every seat has passed. The shortest hand takes it — and a tie
// is not a win, because a win here has to be somebody actually going out.
func (s *State) stuck(evs *[]Event) {
best, tied := 0, false
for seat := range s.Hands {
live := s.alive()
if len(live) == 0 {
s.lose(evs) // can't happen: a mercy kill that empties the table settles first
return
}
best, tied := live[0], false
for _, seat := range live {
switch {
case len(s.Hands[seat]) < len(s.Hands[best]):
best, tied = seat, false
@@ -731,6 +993,17 @@ func (s State) Playable() []int {
}
return nil
}
// Under a stack, the only cards that light up are the ones that answer it.
// Everything else in the hand is dead until the bill is paid.
if s.Phase == PhaseStack {
var out []int
for i, c := range hand {
if c.CanStackOn(s.Color) {
out = append(out, i)
}
}
return out
}
var out []int
for i, c := range hand {
if c.CanPlayOn(s.top(), s.Color) {
@@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) {
return c, true
}
// seatAt is the seat n places round from the one whose turn it is.
// seatAt is the seat n *live* places round from the one whose turn it is.
//
// A seat the mercy rule has killed is not there any more: it is stepped over, not
// landed on and skipped. So this counts living seats rather than doing the
// arithmetic on the index — which is the same thing in a normal game, where
// nobody is ever out, and the only thing that keeps a No Mercy table from
// handing the turn to a corpse.
func (s State) seatAt(n int) int {
seats := len(s.Hands)
return ((s.Turn+s.Dir*n)%seats + seats) % 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 game
}
if s.live(at) {
moved++
}
}
return at
}
// advance moves the turn on n places.
@@ -815,6 +1104,7 @@ func (s State) clone() State {
s.Deck = append([]Card(nil), s.Deck...)
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
s.Out = append([]bool(nil), s.Out...)
return s
}