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

@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
return out
}
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
// with and has to eat the lot.
//
// It plays the *smallest* draw card it holds. The bill is passed on either way —
// what it is passing on is the stack plus whatever it added — so the cheap card
// does the same job as the expensive one, and keeps the +10 in hand for a turn
// when the bot is the one choosing to hurt somebody rather than the one dodging.
//
// The slip is here too: one time in six it reaches for the second-smallest, so a
// player can't read the stack it just passed as a complete inventory of what the
// bot doesn't have.
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
var can []int
for i, c := range hand {
if c.CanStackOn(topColor) {
can = append(can, i)
}
}
if len(can) == 0 {
return Card{}, -1
}
// Smallest draw first. A stable insertion sort: there are never many.
for i := 1; i < len(can); i++ {
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
can[j], can[j-1] = can[j-1], can[j]
}
}
pick := can[0]
if len(can) > 1 && rng.IntN(botSlip) == 0 {
pick = can[1]
}
return hand[pick], pick
}
// botRouletteColor names the colour for a roulette: whichever the bot holds
// *least* of. The victim flips until that colour turns up, so the rarer the
// colour, the longer they flip and the more they keep. Naming the colour you're
// long in is naming the one that ends the flipping soonest, which is mercy — and
// this is not that game.
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
counts := [5]int{}
for _, c := range hand {
if c.Color.Playable() {
counts[c.Color]++
}
}
best, bestN := Wild, 1<<30
for col := Red; col <= Green; col++ {
if counts[col] < bestN {
best, bestN = col, counts[col]
}
}
if best == Wild {
return Red + Color(rng.IntN(4))
}
return best
}
// botColor names a colour for a wild: whichever the bot holds most of, so the
// card it plays next is one it already has. A hand of nothing but wilds picks
// at random rather than always saying red, which would be a tell.

View File

@@ -0,0 +1,244 @@
package uno
import "math/rand/v2"
// No Mercy.
//
// A rules dial, not a fourth table. The table size is still the tier — a duel is
// a duel — and No Mercy is a switch you throw across all three of them. What it
// changes is the game they play:
//
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
// colour roulette.
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
// you hold, and the bill goes to the next seat with the two added on. Whoever
// runs out of draw cards eats the lot.
// - You draw until you can play. There is no drawing one card and shrugging.
// - And twenty-five cards in your hand kills you. That is the whole point of
// the deck: it is built to bury somebody, and the mercy rule is what happens
// when it does.
//
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
// never runs a line of it.
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
// your cards go back in the deck and the table plays on without you.
const MercyLimit = 25
// NewNoMercyDeck builds the 168.
//
// Per colour: two of each number, three skips, two skip-everyones, four
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
// a test wants the order it was built in.
func NewNoMercyDeck() []Card {
d := make([]Card, 0, 168)
for _, col := range []Color{Red, Blue, Yellow, Green} {
for v := Zero; v <= Nine; v++ {
d = append(d, Card{col, v}, Card{col, v})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, Skip})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, SkipAll})
}
for i := 0; i < 4; i++ {
d = append(d, Card{col, Reverse})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawTwo})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawFour})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, DiscardAll})
}
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRevFour})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawSix})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawTen})
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRoulette})
}
return d
}
// CanStackOn reports whether a card can be thrown onto a stack that is already
// building. Any draw card answers any other — there is no escalation rule, so a
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
// the colour in play. The wild draws always go.
//
// This is why the pending count is not a cap: what you are matching is the fact
// of a draw card, not its size.
func (c Card) CanStackOn(topColor Color) bool {
if c.Value.Draw() == 0 {
return false
}
if c.IsWild() {
return true
}
return c.Color == topColor
}
// canStack reports whether a seat holds anything at all it could answer with.
func (s State) canStack(seat int) bool {
for _, c := range s.Hands[seat] {
if c.CanStackOn(s.Color) {
return true
}
}
return false
}
// absorb is what happens when the stack stops with you: you take every card in
// it, and you lose your turn. The pending count is cleared *before* the cards
// land, because a mercy kill inside the draw ends the seat and there must be no
// bill left standing against a seat that is no longer at the table.
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
n := s.Pending
s.Pending = 0
s.deal(seat, n, true, evs, rng)
// The seat can die paying the bill, and a mercy kill can end the whole game —
// the player dying, or the last bot dying and leaving you alone at the table.
// So the phase is only reset if there is still a game to have a phase.
if s.mercy(seat, evs, rng) && s.Phase == PhaseDone {
return
}
if !s.live(seat) {
s.Phase = PhasePlay
s.advance(1)
return // it died, but the table plays on. Don't skip a seat that isn't there.
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
s.Phase = PhasePlay
s.advance(1) // the turn is on the seat that just paid, so it moves one on
}
// roulette is the colour roulette: the next seat turns cards over until the
// named colour comes up, and keeps every card it turned. Then it loses its turn.
//
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
// and even that can be dry), so this is bounded by what there is to draw, not by
// the colour ever actually appearing. A wild is not a colour and never ends it.
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
got := 0
for {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[victim] = append(s.Hands[victim], c)
got++
if c.Color == color {
break
}
if len(s.Hands[victim]) >= MercyLimit {
break // they are dead already; stop dealing cards to a corpse
}
}
if got > 0 {
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
*evs = append(*evs, e)
}
if s.mercy(victim, evs, rng) {
return
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
s.advance(2)
}
// discardAll dumps every remaining card of a colour out of a hand and buries it
// under the card that was just played. The pile keeps its top: the played card
// stays the card in play, and the rest go beneath it, where they are still in the
// game (a reshuffle brings them back) and still count in a census.
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
hand := s.Hands[seat]
kept := make([]Card, 0, len(hand))
var dumped []Card
for _, c := range hand {
if c.Color == color && !c.IsWild() {
dumped = append(dumped, c)
} else {
kept = append(kept, c)
}
}
s.Hands[seat] = kept
if len(dumped) > 0 {
top := s.Discard[len(s.Discard)-1]
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
s.Discard = append(s.Discard, top)
*evs = append(*evs, Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
Color: color, Left: len(kept)})
}
return len(dumped)
}
// mercy checks a seat against the limit and, if it has crossed it, takes it out
// of the game: its cards go back into the deck and it never plays again. It
// reports whether the seat died.
//
// What that *means* depends on who it was. You dying is the game over — the
// stake is gone whatever the bots do next. A bot dying leaves a table with one
// fewer seat, and if it leaves you alone at it, you have won: everybody who could
// have beaten you to the last card is dead.
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
return false
}
n := len(s.Hands[seat])
s.Deck = append(s.Deck, s.Hands[seat]...)
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
s.Hands[seat] = nil
s.Out[seat] = true
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
*evs = append(*evs, Event{Kind: EvMercy, Seat: seat, N: n, Left: 0})
if seat == You {
s.lose(evs)
return true
}
if alive := s.alive(); len(alive) == 1 {
s.settle(alive[0], evs) // you outlived the table
}
return true
}
// alive lists the seats still in the game.
func (s State) alive() []int {
var out []int
for i := range s.Hands {
if s.live(i) {
out = append(out, i)
}
}
return out
}
// live reports whether a seat is still playing. Out is empty in a normal game and
// in any game saved before No Mercy existed, so a missing entry is a living seat.
func (s State) live(seat int) bool {
return seat >= len(s.Out) || !s.Out[seat]
}
// lose ends the game against the player without anybody having gone out — which
// is what a mercy kill on seat zero is.
func (s *State) lose(evs *[]Event) {
s.Phase = PhaseDone
s.Outcome = OutcomeLost
s.Payout = 0
*evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)})
}

View File

@@ -0,0 +1,421 @@
package uno
import (
"math/rand/v2"
"testing"
)
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
func TestNoMercyDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewNoMercyDeck()})
if got := total(m); got != 168 {
t.Fatalf("deck has %d cards, want 168", got)
}
want := map[Card]int{
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
{Blue, Seven}: 2,
{Green, Skip}: 3,
{Yellow, SkipAll}: 2,
{Red, Reverse}: 4,
{Blue, DrawTwo}: 2,
{Green, DrawFour}: 2, // the *coloured* +4
{Yellow, DiscardAll}: 3,
{Wild, WildRevFour}: 8,
{Wild, WildDrawSix}: 4,
{Wild, WildDrawTen}: 4,
{Wild, WildRoulette}: 8,
}
for c, n := range want {
if m[c] != n {
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
}
}
// The normal deck's wilds are not in this one, and its coloured +4 is not in
// the normal one. They are different cards that print the same thing.
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
t.Error("the No Mercy deck should print none of the normal wilds")
}
}
// TestNoMercyCensus is the load-bearing one, and the same one the normal game
// has: 168 cards, each in exactly one place, checked after every move of a
// hundred games played to the end.
//
// It is what would catch the two new ways this deck can lose a card. Discard All
// buries a whole colour under the pile, and a mercy kill shovels a
// twenty-five-card hand back into the deck — either of those dropping a card on
// the floor is a deck that quietly shrinks until the table can't be dealt.
func TestNoMercyCensus(t *testing.T) {
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
for seed := uint64(0); seed < 100; seed++ {
s := deal(t, tier, 100, seed)
start := census(s)
if got := total(start); got != 168 {
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
}
rng := rand.New(rand.NewPCG(seed, 99))
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
next, _, err := ApplyMove(s, naive(s, rng))
if err != nil {
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
}
s = next
if got := census(s); total(got) != 168 {
t.Fatalf("%s seed %d: %d cards after a move, want 168",
tier.Slug, seed, total(got))
}
}
if s.Phase != PhaseDone {
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
}
}
}
}
// naive is the strategy the multiples are priced against: play the first legal
// card you hold, take a stack you can't answer, and draw when you have nothing.
// It is a real way to play and a bad one, which is exactly what a house edge is
// measured against.
func naive(s State, rng *rand.Rand) Move {
if s.Phase == PhaseStack {
if p := s.Playable(); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveTake}
}
if p := s.Playable(); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveDraw}
}
// stack loads a seat's hand up to n cards by taking them off the deck, so the
// table still holds 168 of them. Every card it moves is one that can't be played
// on the pile, which is what a hand on its way to the mercy limit looks like.
func stack(s *State, seat, n int) {
// Every card the seat was holding goes back in the deck first, so the table is
// whole before we take n out of it again. The pile keeps whatever the deal
// turned over — replacing it with a card of our choosing would quietly destroy
// one, and the census below would blame the engine for it.
s.Deck = append(s.Deck, s.Hands[seat]...)
s.Hands[seat] = nil
s.Color = s.top().Color
kept := make([]Card, 0, len(s.Deck))
for _, c := range s.Deck {
if len(s.Hands[seat]) < n {
s.Hands[seat] = append(s.Hands[seat], c)
continue
}
kept = append(kept, c)
}
s.Deck = kept
}
func playMove(s State, idx int, rng *rand.Rand) Move {
m := Move{Kind: MovePlay, Index: idx}
if s.Hands[You][idx].IsWild() {
m.Color = Red + Color(rng.IntN(4))
}
return m
}
// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a
// draw card doesn't land on you, it *opens a bill*, and the seat that can't
// answer pays the whole thing.
func TestAStackIsPassedOnAndPaid(t *testing.T) {
s := deal(t, nmDuel(), 100, 7)
// Rig it: you hold a +2 on a red pile, the bot holds one card that can answer
// and one that can't.
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
s.Turn = You
s.Phase = PhasePlay
// You play the +2. The bot answers with its own, so the bill comes back to you
// at four — and you have nothing to answer with, so you pay it.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if next.Phase != PhaseStack {
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
}
if next.Turn != You {
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
}
if next.Pending != 4 {
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
}
if !hasKind(evs, EvStack) {
t.Error("no stack event: the felt has nothing to show the player")
}
// You cannot draw your way out of it, and you cannot play a card that isn't a
// draw card.
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack {
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
}
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
}
// Pay it. The bot is left holding one card it cannot play, and — because No
// Mercy makes it draw until it can — it will draw into a fresh hand and may
// well open a *new* stack on the way. That's the game working, not a leak, so
// what's asserted here is the bill this seat paid, not the state of the table
// afterwards: four cards into the hand, and the bill discharged.
before := len(next.Hands[You])
paid, evs, err := ApplyMove(next, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
var forced int
for _, e := range evs {
if e.Kind == EvForced && e.Seat == You {
forced = e.N
}
}
if forced != 4 {
t.Errorf("the stack made you take %d cards, want 4", forced)
}
if len(paid.Hands[You]) < before+4 {
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
}
// The bill you paid is gone. Anything pending now is a new stack the bot
// opened after yours was settled, and it is never the one you just paid.
if paid.Pending == 4 && paid.Phase == PhaseStack {
t.Error("the bill you just paid is still standing")
}
}
// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the
// stake is gone the moment the hand hits the limit, whoever else is still playing.
func TestTwentyFiveCardsKillsYou(t *testing.T) {
s := deal(t, nmFull(), 100, 3)
// Twenty-four cards in your hand, and a stack of ten pointed at you.
//
// The cards are *moved* from the deck, not invented: a fixture that conjures
// a hand out of nothing breaks the census before the engine gets a chance to,
// and then the census assertion below is testing the fixture instead of the
// mercy rule.
stack(&s, You, 24)
s.Turn = You
s.Phase = PhaseStack
s.Pending = 10
next, evs, err := ApplyMove(s, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
}
if next.Phase != PhaseDone || next.Outcome != OutcomeLost {
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
}
if next.Payout != 0 {
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
}
if len(next.Hands[You]) != 0 || next.live(You) {
t.Error("a dead seat should hold no cards and be out of the game")
}
if got := total(census(next)); got != 168 {
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
}
}
// TestOutlivingTheTableWins is the other side of the mercy rule, and the one
// that makes No Mercy pay less than it looks like it should: the deck buries bots
// too, and a table with every bot dead is a table you have won.
func TestOutlivingTheTableWins(t *testing.T) {
s := deal(t, nmDuel(), 100, 11)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = make([]Card, 0, 24)
for i := 0; i < 24; i++ {
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("the bot should have died taking the stack")
}
if next.Phase != PhaseDone || next.Outcome != OutcomeWon {
t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins",
next.Phase, next.Outcome)
}
if next.Payout != next.Pays() {
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
}
}
// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only
// moves on when the deck itself has nothing left.
func TestYouDrawUntilYouCanPlay(t *testing.T) {
s := deal(t, nmDuel(), 100, 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, One}} // nothing playable
// A deck whose first two cards are dead and whose third plays.
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
s.Turn = You
s.Phase = PhasePlay
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[You]) != 4 {
t.Fatalf("hand is %d, want 4: you draw until something plays",
len(next.Hands[You]))
}
if next.Phase != PhaseDrawn {
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play",
next.Phase)
}
// And you may not pass on it: you drew for it, you play it.
if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow {
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
}
}
// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never
// actually leaves the seat that played it.
func TestSkipAllComesBackToYou(t *testing.T) {
s := deal(t, nmFull(), 100, 13)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip-all: %v", err)
}
if next.Turn != You {
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
}
if !hasKind(evs, EvSkipAll) {
t.Error("no skipall event")
}
}
// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the
// game — buried under the pile, not deleted.
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s := deal(t, nmDuel(), 100, 17)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play discard-all: %v", err)
}
if len(next.Hands[You]) != 1 {
t.Fatalf("hand is %d, want 1: every red should have gone with it",
len(next.Hands[You]))
}
if next.Hands[You][0] != (Card{Blue, Two}) {
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
}
if top := next.Top(); top.Value != DiscardAll {
t.Errorf("the card in play is %v, want the discard-all that was played", top.Value)
}
if !hasKind(evs, EvDiscardAll) {
t.Error("no discard event")
}
if got := total(census(next)); got != before {
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
}
}
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
func TestRouletteFlipsUntilTheColour(t *testing.T) {
s := deal(t, nmDuel(), 100, 19)
s.Color = Blue
s.Discard = []Card{{Blue, Five}}
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
s.Hands[1] = []Card{{Green, Three}}
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
s.Turn = You
s.Phase = PhasePlay
// Name red: the bot flips blue, green, yellow, red — four cards — and keeps them.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red})
if err != nil {
t.Fatalf("play roulette: %v", err)
}
var got int
for _, e := range evs {
if e.Kind == EvRoulette {
got = e.N
}
}
if got != 4 {
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
}
// One card it started with, plus the four it turned. (The bot is then skipped,
// so the turn is back with you and it never played any of them.)
if n := len(next.Hands[1]); n != 5 {
t.Errorf("the bot holds %d, want 5", n)
}
if total(census(next)) != total(census(s)) {
t.Error("the roulette lost a card")
}
}
// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and
// checks each tier still charges roughly the house's edge for it.
//
// This is the test that fails when somebody changes the bots, the deck, or a
// rule, and it is *supposed* to: the tier and the game it prices are a pair. If
// this goes red, re-measure and move the number, don't loosen the bound.
func TestTheMultiplesAreStillPriced(t *testing.T) {
if testing.Short() {
t.Skip("slow: plays thousands of games")
}
for _, tier := range AllTiers() {
wins, games := 0, 3000
for seed := 0; seed < games; seed++ {
s := deal(t, tier, 100, uint64(seed)+7777)
rng := rand.New(rand.NewPCG(uint64(seed), 4242))
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
next, _, err := ApplyMove(s, naive(s, rng))
if err != nil {
t.Fatalf("%s: %v", tier.Slug, err)
}
s = next
}
if s.Outcome.Won() {
wins++
}
}
p := float64(wins) / float64(games)
// What a staked chip comes back as, playing badly: you win p of the time and
// keep the multiple less the rake on the profit, and lose the stake the rest.
ev := p*(1+(tier.Base-1)*(1-rake)) - 1
t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%",
tier.Slug, tier.Bots, tier.Base, p*100, -ev*100)
if ev < -0.14 || ev > -0.02 {
t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 214%% "+
"band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.",
tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1)
}
}
}

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
}

View File

@@ -39,8 +39,11 @@ func census(s State) map[Card]int {
}
for _, c := range s.Discard {
// A wild is stamped with the colour it was played as while it sits on the
// pile, so it counts as the wild it really is.
if c.Value == WildCard || c.Value == WildDrawFour {
// pile, so it counts as the wild it really is. This asks the face, rather
// than listing the wilds: No Mercy prints four more of them, and a census
// that didn't know about them would count a played roulette as a red card
// and report a deck that balances while the cards don't.
if c.Value.Wild() {
c.Color = Wild
}
m[c]++
@@ -478,22 +481,37 @@ func TestQuoteIsThePayout(t *testing.T) {
}
// The rake comes out of the winnings, never the stake.
//
// The arithmetic is derived from the tier rather than written down. It used to be
// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When
// the tiers were re-measured and repriced, this test failed on a rake that was
// perfectly correct, which is a test asserting a *price* while claiming to assert
// a *rule*. The rule is: the house takes its cut of the profit and never touches
// the stake. That holds at any multiple.
func TestRakeIsOnWinningsOnly(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
s.Tier = duel()
s.Bet = 100
gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house
profit := gross - s.Bet
wantRake := int64(float64(profit) * rake)
wantPayout := s.Bet + profit - wantRake
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Payout != 214 {
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
if next.Payout != wantPayout {
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
next.Payout, wantPayout, s.Bet, profit, wantRake)
}
if next.Rake != 6 {
t.Errorf("rake %d, want 6", next.Rake)
if next.Rake != wantRake {
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
next.Rake, wantRake, s.Bet)
}
if next.Net() != 114 {
t.Errorf("net %d, want 114", next.Net())
if next.Net() != wantPayout-s.Bet {
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
}
}