package uno import "testing" // The UNO call, and the catch on the other side of it. See call.go. // oneCardAway sets you up holding two cards, both of which go on the pile, so // playing either one takes you to UNO. // // The bot is given a hand that can't touch a red pile and a deck that can't help // it, so whatever it does on its turn, it does not make you draw. That matters: // a hand that grows spends the call (see tidyCalls), which is correct and would // otherwise make these tests flap on the seeds where the bot happens to turn up // a +2. func oneCardAway(t *testing.T, seed uint64) State { t.Helper() s := deal(t, duel(), 100, seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Red, Two}} s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}} s.Deck = make([]Card, 24) for i := range s.Deck { s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites } s.Turn = You s.Phase = PhasePlay return s } // TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you. // Across a spread of seeds, not one bot ever gets a catch. func TestCallingUnoKeepsYouSafe(t *testing.T) { for seed := uint64(0); seed < 200; seed++ { s := oneCardAway(t, seed) next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true}) if err != nil { t.Fatalf("seed %d: play: %v", seed, err) } if hasKind(evs, EvCaught) { t.Fatalf("seed %d: caught after calling UNO", seed) } if !hasKind(evs, EvUno) { t.Fatalf("seed %d: called UNO and the table never said so", seed) } if !next.Called[You] { t.Fatalf("seed %d: the call wasn't recorded", seed) } } } // TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you // for two. It gets one look, so it misses sometimes; over 400 games it should // land near botAlert, and the two cards should actually arrive. func TestForgettingUnoGetsYouCaught(t *testing.T) { caught, games := 0, 400 for seed := uint64(0); seed < uint64(games); seed++ { s := oneCardAway(t, seed) next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("seed %d: play: %v", seed, err) } if !hasKind(evs, EvCaught) { continue } caught++ // One card left after the play, plus the two the catch cost. if n := len(next.Hands[You]); n != 3 { t.Fatalf("seed %d: caught and holding %d, want 3", seed, n) } if hasKind(evs, EvUno) { t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed) } } rate := float64(caught) / float64(games) if rate < botAlert-0.08 || rate > botAlert+0.08 { t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)", rate*100, botAlert*100) } } // TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so // forgetting at a full table is very nearly always punished. func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) { away := func(tier Tier, seed uint64) bool { s := deal(t, tier, 100, seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Red, Two}} s.Turn = You s.Phase = PhasePlay _, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play: %v", err) } return !hasKind(evs, EvCaught) } var got [2]float64 for i, tier := range []Tier{duel(), table()} { escapes := 0 for seed := uint64(0); seed < 400; seed++ { if away(tier, seed) { escapes++ } } got[i] = float64(escapes) / 400 } if got[1] >= got[0] { t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+ "two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100) } } // quietBot puts a bot on one card it never called, with the turn back on you. func quietBot(t *testing.T, called bool) State { t.Helper() s := deal(t, duel(), 100, 21) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}} s.Hands[1] = []Card{{Yellow, Nine}} s.Called = []bool{false, called} s.Turn = You s.Phase = PhasePlay return s } // TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it, // and the turn is still yours: catching is not a move you spend a turn on. func TestCatchingAQuietBot(t *testing.T) { s := quietBot(t, false) before := total(census(s)) next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1}) if err != nil { t.Fatalf("catch: %v", err) } if !hasKind(evs, EvCaught) { t.Fatal("no catch event") } if n := len(next.Hands[1]); n != 3 { t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n) } if n := len(next.Hands[You]); n != 3 { t.Errorf("your hand is %d, want 3: a catch costs you nothing", n) } if next.Turn != You { t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn) } if total(census(next)) != before { t.Error("the catch lost a card") } } // TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all. // Either way you've accused it of nothing, and that is two cards to you. func TestCatchingACleanBotCostsYou(t *testing.T) { for _, tc := range []struct { name string state State }{ {"it called", quietBot(t, true)}, {"it isn't even close", func() State { s := quietBot(t, false) s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}} return s }()}, } { t.Run(tc.name, func(t *testing.T) { next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1}) if err != nil { t.Fatalf("catch: %v", err) } if !hasKind(evs, EvMiscall) { t.Fatal("no miscall event") } if n := len(next.Hands[You]); n != 5 { t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n) } if next.Turn != You { t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn) } }) } } // TestYouCannotCatchYourself, or a seat that isn't at the table. func TestYouCannotCatchYourself(t *testing.T) { s := quietBot(t, false) for _, seat := range []int{You, -1, 9} { if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch { t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err) } } } // TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work // your way back down to one: that is a new call you owe, not the old one still // standing. Without this a seat could be caught out once and never again. func TestACallIsSpentWhenTheHandGrows(t *testing.T) { s := deal(t, duel(), 100, 5) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}} s.Called = []bool{true, false} s.Turn = You s.Phase = PhasePlay // Draw, and the hand is two: the word you said was about a card you no longer // hold on its own. next, _, err := ApplyMove(s, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } if next.Called[You] { t.Error("the call survived the hand growing; it should be spent") } } // TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else. func TestCatchable(t *testing.T) { s := quietBot(t, false) if got := s.Catchable(); len(got) != 1 || got[0] != 1 { t.Errorf("Catchable() = %v, want [1]", got) } clean := quietBot(t, true) if got := clean.Catchable(); len(got) != 0 { t.Errorf("Catchable() = %v on a bot that called, want none", got) } // And not on somebody else's turn: you can only call it out when it's on you. off := quietBot(t, false) off.Turn = 1 if got := off.Catchable(); len(got) != 0 { t.Errorf("Catchable() = %v off-turn, want none", got) } } // TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine // which cards take you to one, rather than counting your hand itself. "Discard // all" takes every card of its colour with it, so a six-card hand can land on // one, and a browser subtracting one from six gets a player caught. func TestUnoAtSeesThroughDiscardAll(t *testing.T) { s := deal(t, nmDuel(), 100, 3) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}} s.Turn = You s.Phase = PhasePlay // Index 0 dumps itself and the three other reds: five cards become one. // Index 4 is an ordinary play: five become four. got := s.UnoAt() if len(got) != 1 || got[0] != 0 { t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got) } } // TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call. func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) { s := oneCardAway(t, 1) got := s.UnoAt() if len(got) != 2 { t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got) } } // TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody // owes the table a word for winning. func TestGoingOutNeedsNoCall(t *testing.T) { s := deal(t, duel(), 100, 9) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Red, One}} s.Turn = You s.Phase = PhasePlay next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("play the last card: %v", err) } if !next.Outcome.Won() { t.Fatalf("outcome %q, want a win", next.Outcome) } if hasKind(evs, EvCaught) { t.Error("caught for not calling UNO on the card that won the game") } } // TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the // count beside the fan. If a quiet bot emitted anything at all there'd be nothing // to spot. func TestABotThatForgetsSaysNothing(t *testing.T) { quiet := 0 for seed := uint64(0); seed < 300 && quiet < 1; seed++ { s := deal(t, duel(), 100, seed) s.Color = Red s.Discard = []Card{{Red, Five}} s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}} s.Hands[1] = []Card{{Red, One}, {Red, Nine}} s.Turn = 1 s.Phase = PhasePlay s.Turn = You // the bot plays on the back of your move // Draw, handing the turn over: the bot plays a red and lands on one card. next, evs, err := ApplyMove(s, Move{Kind: MoveDraw}) if err != nil { t.Fatalf("draw: %v", err) } if len(next.Hands[1]) != 1 || next.Called[1] { continue // it either didn't get down to one, or it remembered } quiet++ if hasKind(evs, EvUno) { t.Error("a bot that forgot to call still announced it") } if len(next.Catchable()) != 1 { t.Error("a quiet bot on one card isn't catchable") } } if quiet == 0 { t.Skip("no bot forgot in 300 games; botForget may have been turned down") } }