Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "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 walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
324 lines
10 KiB
Go
324 lines
10 KiB
Go
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")
|
|
}
|
|
}
|