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 2–14%% "+ "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) } } }