Files
Pete/internal/games/uno/uno_test.go
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
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
2026-07-14 18:37:51 -07:00

808 lines
25 KiB
Go

package uno
import (
"encoding/json"
"math/rand/v2"
"testing"
)
const rake = 0.05
// You is the human's seat in the solo tests, a test-local alias for what the
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
// human is a convention only these fixtures keep.
const You = 0
func duel() Tier { t, _ := TierBySlug("duel"); return t }
func full() Tier { t, _ := TierBySlug("full"); return t }
func table() Tier { t, _ := TierBySlug("table"); return t }
// openSolo opens a solo table (one human, the tier's bots) without dealing.
func openSolo(t *testing.T, tier Tier, seed uint64) State {
t.Helper()
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
// deal opens a solo table and deals the first hand, so the human is to act.
func deal(t *testing.T, tier Tier, seed uint64) State {
t.Helper()
s := openSolo(t, tier, seed)
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
if err != nil {
t.Fatalf("deal: %v", err)
}
if !hasKind(evs, EvDeal) {
t.Fatalf("the deal emitted no deal event: %+v", evs)
}
if s.Turn != You {
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
}
return s
}
// census counts every card in the game, wherever it is. It is the invariant the
// whole engine has to hold: 108 cards, each of them in exactly one place.
func census(s State) map[Card]int {
m := map[Card]int{}
for _, h := range s.Hands {
for _, c := range h {
m[c]++
}
}
for _, c := range s.Deck {
m[c]++
}
for _, c := range s.Discard {
if c.Value.Wild() {
c.Color = Wild
}
m[c]++
}
return m
}
func total(m map[Card]int) int {
n := 0
for _, v := range m {
n += v
}
return n
}
func TestNewDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewDeck()})
if got := total(m); got != 108 {
t.Fatalf("deck has %d cards, want 108", got)
}
if m[Card{Red, Zero}] != 1 {
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
}
if m[Card{Blue, Seven}] != 2 {
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
}
if m[Card{Wild, WildDrawFour}] != 4 {
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
}
}
func TestNewDeals(t *testing.T) {
s := deal(t, full(), 7)
if len(s.Hands) != 4 {
t.Fatalf("full house is four seats, got %d", len(s.Hands))
}
for i, h := range s.Hands {
if len(h) != HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
}
}
bots := 0
for _, seat := range s.Seats {
if seat.Bot {
bots++
if seat.Name == "" {
t.Error("a bot seat has no name")
}
}
}
if bots != 3 {
t.Fatalf("want three bots, got %d", bots)
}
if got := total(census(s)); got != 108 {
t.Fatalf("the deal lost cards: %d of 108", got)
}
// Every seat anted, so the pot is one ante per seat.
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
}
}
// The card turned over to start is never an action card — see dealHand.
func TestOpeningCardIsANumber(t *testing.T) {
for seed := uint64(0); seed < 300; seed++ {
s := deal(t, table(), seed)
if s.Top().Value.Action() {
t.Fatalf("seed %d opened on %v", seed, s.Top())
}
if s.Color != s.Top().Color {
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
}
}
}
// ---- the rules ------------------------------------------------------------
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
// that happens to deal it. Every seat has anted, so the pot is set and a win
// settles for real.
func rig(hands [][]Card, top Card, color Color) State {
left := map[Card]int{}
for _, c := range NewDeck() {
left[c]++
}
take := func(c Card) {
if c.IsWild() {
c.Color = Wild
}
left[c]--
}
for _, h := range hands {
for _, c := range h {
take(c)
}
}
take(top)
var deck []Card
for _, c := range NewDeck() {
if left[c] > 0 {
left[c]--
deck = append(deck, c)
}
}
ante := full().Ante
seats := make([]Seat, len(hands))
for i := range seats {
// The stack is what is left after this seat anted: a real deal moves the ante
// out of the stack and into the pot, so a refund or a win returns it here.
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
}
seats[You].Name = "You"
return State{
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
Pot: ante * int64(len(hands)), Winner: -1,
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
RakePct: rake, Seed1: 1, Seed2: 2,
}
}
func TestPlayMustMatch(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
}
}
func TestPlayMatchesFaceOrColor(t *testing.T) {
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
}
if next.Color != Blue {
t.Errorf("colour in play should follow the card: %v", next.Color)
}
if evs[0].Kind != EvPlay || evs[0].Seat != You {
t.Errorf("first event should be your play, got %+v", evs[0])
}
}
func TestWildNeedsAColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
t.Fatalf("a wild with no colour should be refused, got %v", err)
}
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
}
}
func TestWildNamesTheColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
if err != nil {
t.Fatalf("play wild: %v", err)
}
top := next.Discard
if len(top) < 2 {
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
}
if top[1] != (Card{Green, WildCard}) {
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
}
}
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if len(next.Hands[1]) != 4 {
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
}
if next.Turn != You {
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
}
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
}
if got := total(census(next)); got != 108 {
t.Fatalf("the +2 lost cards: %d of 108", got)
}
}
func TestReverseIsASkipHeadsUp(t *testing.T) {
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != 1 {
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
}
if next.Turn != You {
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
}
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
}
if len(next.Hands[1]) != 1 {
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
}
}
func TestReverseTurnsTheTableAround(t *testing.T) {
s := rig([][]Card{
{{Red, Reverse}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
{{Red, Seven}, {Yellow, Six}},
}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != -1 {
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
}
if !hasKind(evs, EvReverse) {
t.Errorf("want a reverse event: %+v", evs)
}
if next.Turn != You {
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
}
var order []int
for _, e := range evs {
if e.Kind == EvPlay && e.Seat != You {
order = append(order, e.Seat)
}
}
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
}
}
func TestSkipSkips(t *testing.T) {
s := rig([][]Card{
{{Red, Skip}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
}, Card{Red, Nine}, Red)
s.Tier = table()
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip: %v", err)
}
if !hasKind(evs, EvSkip) {
t.Errorf("want a skip event: %+v", evs)
}
for _, e := range evs {
if e.Kind == EvPlay && e.Seat == 1 {
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
}
}
if len(next.Hands[1]) != 2 {
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
}
if len(next.Hands[2]) != 1 {
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
}
}
// ---- drawing --------------------------------------------------------------
func TestDrawnPlayableWaitsForYou(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhaseDrawn {
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
}
if next.Turn != You {
t.Fatalf("the turn should still be yours: %d", next.Turn)
}
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
}
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
t.Errorf("the drawn card, and only it, is playable: %v", got)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
t.Fatalf("only the drawn card may be played, got %v", err)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
t.Fatalf("you can't draw twice, got %v", err)
}
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
if err != nil {
t.Fatalf("pass: %v", err)
}
if after.Phase != PhasePlay || after.Turn != You {
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
}
if len(after.Hands[You]) != 2 {
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
}
}
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhasePlay {
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
}
if !hasKind(evs, EvPass) {
t.Errorf("the turn passed, and the table should be told: %+v", evs)
}
}
func TestPassOnlyAfterADraw(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
}
}
// dead is a table nobody can move at: the deck is spent, the discard is one card
// deep, and not a seat holds a card that goes on the pile.
func dead(hands [][]Card) State {
s := rig(hands, Card{Red, Nine}, Red)
s.Deck = nil
return s
}
// A dead table ends the hand rather than passing the turn round forever. It no
// longer ends the *session* — a shared table plays another hand — so it lands on
// PhaseHandOver, not PhaseDone.
func TestDeadTableEnds(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.playing() {
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
}
if next.Phase != PhaseHandOver {
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
}
if next.Outcome != OutcomeTie {
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
}
// A tie hands the antes back: every seat is whole again.
for i := range next.Seats {
if next.Seats[i].Stack != 1000 {
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
}
}
if !hasKind(evs, EvSettle) {
t.Errorf("the table has to be told the hand is over: %+v", evs)
}
}
// And the shortest hand takes the pot, which is the one way a stuck table pays.
func TestDeadTablePaysTheShortestHand(t *testing.T) {
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
pot := s.Pot
before := s.Seats[You].Stack
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Outcome != OutcomeStuck || next.Winner != You {
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
}
profit := pot - s.Tier.Ante
wantRake := int64(float64(profit) * rake)
wantWon := pot - wantRake
if next.Seats[You].Won != wantWon {
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
}
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
}
}
func TestReshuffleRebuildsTheDeck(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = nil
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw on an empty deck: %v", err)
}
if !hasKind(evs, EvReshuffle) {
t.Fatalf("want a reshuffle: %+v", evs)
}
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
t.Errorf("the card in play stays on the pile: %v", next.Discard)
}
for _, c := range next.Deck {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild went back into the deck stamped %v", c.Color)
}
}
for _, h := range next.Hands {
for _, c := range h {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild was dealt out stamped %v", c.Color)
}
}
}
}
// ---- the pot --------------------------------------------------------------
// The winner takes the pot, and the house's rake comes out of the winnings, never
// out of a seat's own ante.
func TestWinnerTakesThePotLessRake(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
pot := s.Pot
before := s.Seats[You].Stack
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Outcome != OutcomeWon || next.Winner != You {
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
}
profit := pot - s.Tier.Ante
wantRake := int64(float64(profit) * rake)
wantWon := pot - wantRake
if next.Rake != wantRake {
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
}
if next.LastPot != pot {
t.Errorf("last pot %d, want %d", next.LastPot, pot)
}
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
}
if next.Paid != wantRake {
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
}
}
// A bot winning rakes nothing: the house already keeps the whole pot when its own
// seat takes it, so there is nothing to charge.
func TestABotWinningRakesNothing(t *testing.T) {
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
// reaches it.
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
pot := s.Pot
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
if next.Outcome != OutcomeWon || next.Winner != 1 {
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
}
if next.Rake != 0 {
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
}
if next.Seats[1].Won != pot {
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
}
// You anted and lost it: your stack is down exactly one ante.
if next.Seats[You].Stack != 1000-s.Tier.Ante {
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
}
last := evs[len(evs)-1]
if last.Kind != EvSettle || last.Seat != 1 {
t.Errorf("the settle should name the winner: %+v", last)
}
}
// A hand ending returns the table to handover, ready to deal again — it does not
// take a hand move.
func TestNoHandMoveBetweenHands(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if over.Phase != PhaseHandOver {
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
}
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
t.Fatalf("no hand is in progress between hands, got %v", err)
}
}
// You can deal the next hand, ante again, and play on — the session shape.
func TestDealTheNextHand(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
if err != nil {
t.Fatalf("deal the next hand: %v", err)
}
if again.HandNo != over.HandNo+1 {
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
}
if !again.playing() {
t.Fatalf("the next hand should be live, phase %q", again.Phase)
}
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
t.Errorf("the deal antes and turns a card: %+v", evs)
}
}
func TestBadBuyIn(t *testing.T) {
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
}
}
// ---- the whole game -------------------------------------------------------
// playOut plays one hand to its end with a simple strategy: play the first legal
// card, take a stack you can't answer, otherwise draw, otherwise pass.
func playOut(t *testing.T, s State, maxTurns int) State {
t.Helper()
for turn := 0; s.playing(); turn++ {
if turn > maxTurns {
t.Fatalf("the hand never ended in %d turns", maxTurns)
}
if s.Turn != You {
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
}
var m Move
if p := s.Playable(You); len(p) > 0 {
m = Move{Kind: MovePlay, Index: p[0]}
if s.Hands[You][p[0]].IsWild() {
m.Color = Green
}
} else if s.Phase == PhaseStack {
m = Move{Kind: MoveTake}
} else if s.Phase == PhaseDrawn {
m = Move{Kind: MovePass}
} else {
m = Move{Kind: MoveDraw}
}
next, evs, err := ApplyMove(s, You, m)
if err != nil {
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
}
if len(evs) == 0 {
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
}
if got := total(census(next)); got != 108 {
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
}
for c, n := range census(next) {
if want := deckCount(c); n != want {
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
}
}
s = next
}
return s
}
// deckCount is how many of a given card a 108 deck holds.
func deckCount(c Card) int {
switch {
case c.Color == Wild:
return 4
case c.Value == Zero:
return 1
default:
return 2
}
}
// A hundred hands, played out, with the invariants checked at every step.
func TestGamesPlayOut(t *testing.T) {
yous, others, ties := 0, 0, 0
for seed := uint64(0); seed < 100; seed++ {
tier := Tiers[seed%3]
end := playOut(t, deal(t, tier, seed), 500)
if end.Phase != PhaseHandOver {
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
}
switch {
case end.Winner == You:
yous++
case end.Winner >= 0:
others++
case end.Outcome == OutcomeTie:
ties++
default:
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
}
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
t.Fatalf("seed %d: the winner is still holding cards", seed)
}
}
if yous == 0 || others == 0 {
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
}
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
}
// The same seed deals the same hand and the bots make the same choices.
func TestReplaysFromTheSeed(t *testing.T) {
a := playOut(t, deal(t, full(), 42), 500)
b := playOut(t, deal(t, full(), 42), 500)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if string(ja) != string(jb) {
t.Fatal("the same seed played the same way gave two different games")
}
if a.Winner < 0 && a.Outcome != OutcomeTie {
t.Fatal("the replay didn't finish")
}
}
// A game in progress survives a redeploy: it round-trips through its JSON.
func TestStateSurvivesStorage(t *testing.T) {
s := deal(t, table(), 9)
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
again, _ := json.Marshal(back)
if string(again) != string(blob) {
t.Fatal("a stored game came back different")
}
playOut(t, back, 500)
}
// A move the engine refuses leaves the caller's state exactly as it was.
func TestARefusedMoveChangesNothing(t *testing.T) {
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
before, _ := json.Marshal(s)
for _, m := range []Move{
{Kind: MovePlay, Index: 0}, // doesn't match
{Kind: MovePlay, Index: 1}, // wild with no colour
{Kind: MovePlay, Index: 9}, // no such card
{Kind: MovePass}, // nothing drawn
{Kind: "shuffle-the-deck-in-my-favour"}, // no
} {
if _, _, err := ApplyMove(s, You, m); err == nil {
t.Fatalf("%+v should have been refused", m)
}
}
after, _ := json.Marshal(s)
if string(before) != string(after) {
t.Fatal("a refused move touched the state")
}
}
// The bots choose. Two different seeds should not play the same game.
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
same := 0
for seed := uint64(0); seed < 20; seed++ {
a := playOut(t, deal(t, duel(), seed), 500)
b := playOut(t, deal(t, duel(), seed+1000), 500)
if len(a.Discard) == len(b.Discard) {
same++
}
}
if same == 20 {
t.Fatal("every seed played out to the same length — the bots aren't choosing")
}
}
func TestBotSavesTheDrawFour(t *testing.T) {
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
top, color := Card{Red, Nine}, Red
rng := rand.New(rand.NewPCG(1, 2))
held := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
held++
}
}
if held < 30 {
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
}
reached := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
reached++
}
}
if reached < 30 {
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
}
}
func TestBotPicksItsBestColor(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 4))
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
if got := botColor(hand, rng); got != Blue {
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
}
for i := 0; i < 20; i++ {
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
t.Fatalf("botColor named %v, which is not a colour", got)
}
}
}
func TestBotHasNothingToPlay(t *testing.T) {
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
}
}
func hasKind(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}