Files
Pete/internal/games/uno/nomercy_test.go
prosolis aca523e511 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
2026-07-14 10:07:55 -07:00

422 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}