Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
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(), 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, You, 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, You, 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, 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, You, 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(), 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, You, 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, You, 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, You, 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(), 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, You, 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(You); len(got) != 1 || got[0] != 1 {
|
|
t.Errorf("Catchable() = %v, want [1]", got)
|
|
}
|
|
clean := quietBot(t, true)
|
|
if got := clean.Catchable(You); 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(You); 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(), 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(You)
|
|
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(You)
|
|
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(), 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, You, Move{Kind: MovePlay, Index: 0})
|
|
if err != nil {
|
|
t.Fatalf("play the last card: %v", err)
|
|
}
|
|
if next.Winner != You {
|
|
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, 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(), 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, You, 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(You)) != 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")
|
|
}
|
|
}
|