UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
737 lines
23 KiB
Go
737 lines
23 KiB
Go
package uno
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math/rand/v2"
|
|
"testing"
|
|
)
|
|
|
|
const rake = 0.05
|
|
|
|
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
|
func full() Tier { t, _ := TierBySlug("full"); return t }
|
|
func table() Tier { t, _ := TierBySlug("table"); return t }
|
|
|
|
// deal starts a game, failing the test if it can't.
|
|
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
|
|
t.Helper()
|
|
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if len(evs) != 1 || evs[0].Kind != EvDeal {
|
|
t.Fatalf("New should deal exactly one event, got %+v", evs)
|
|
}
|
|
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 {
|
|
// A wild is stamped with the colour it was played as while it sits on the
|
|
// pile, so it counts as the wild it really is.
|
|
if c.Value == WildCard || c.Value == WildDrawFour {
|
|
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(), 100, 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)
|
|
}
|
|
}
|
|
if len(s.Bots) != 3 {
|
|
t.Fatalf("want three bot names, got %v", s.Bots)
|
|
}
|
|
if s.Turn != You {
|
|
t.Errorf("you play first, turn is %d", s.Turn)
|
|
}
|
|
if got := total(census(s)); got != 108 {
|
|
t.Fatalf("the deal lost cards: %d of 108", got)
|
|
}
|
|
}
|
|
|
|
// The card turned over to start is never an action card — see New.
|
|
func TestOpeningCardIsANumber(t *testing.T) {
|
|
for seed := uint64(0); seed < 300; seed++ {
|
|
s := deal(t, table(), 50, 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 state by hand, so a rule can be tested without hunting a seed
|
|
// that happens to deal it.
|
|
//
|
|
// The deck is the rest of the deck: every card not in a hand and not the one in
|
|
// play. So a rigged game still holds 108 cards, and the census invariant means
|
|
// something in these tests too.
|
|
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() {
|
|
key := c
|
|
if left[key] > 0 {
|
|
left[key]--
|
|
deck = append(deck, c)
|
|
}
|
|
}
|
|
return State{
|
|
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
|
|
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
|
|
Bet: 100, 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, 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) {
|
|
// Same face, different colour: legal.
|
|
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
|
next, evs, err := ApplyMove(s, 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, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
|
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
|
}
|
|
if _, _, err := ApplyMove(s, 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, Move{Kind: MovePlay, Index: 0, Color: Green})
|
|
if err != nil {
|
|
t.Fatalf("play wild: %v", err)
|
|
}
|
|
// The bot moved after us, so the colour in play is whatever it left behind —
|
|
// what we can check is that the wild itself went down as green.
|
|
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) {
|
|
// Two seats, so the +2 lands on the bot and the turn comes straight back.
|
|
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
|
s.Tier = duel()
|
|
next, evs, err := ApplyMove(s, 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, 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) {
|
|
// Every bot holds a red card, so each of them can play the moment the turn
|
|
// reaches it — which is what makes the *order* they play in observable.
|
|
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, 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)
|
|
}
|
|
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
|
|
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) {
|
|
// Both bots hold a playable red, so the only reason either of them doesn't
|
|
// play is that it wasn't asked to.
|
|
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, 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, 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(); len(got) != 1 || got[0] != 1 {
|
|
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
|
}
|
|
// You may not play the *other* card instead — drawing would otherwise be a
|
|
// free look with no cost.
|
|
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
|
t.Fatalf("only the drawn card may be played, got %v", err)
|
|
}
|
|
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
|
t.Fatalf("you can't draw twice, got %v", err)
|
|
}
|
|
after, _, err := ApplyMove(next, 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, 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, Move{Kind: MovePass}); err != ErrCantPass {
|
|
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
|
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
|
// An empty deck, and a discard with something under the top card to become one.
|
|
// The buried wild went down as green; it has to come back as a wild.
|
|
s.Deck = nil
|
|
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
|
next, evs, err := ApplyMove(s, 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 money ------------------------------------------------------------
|
|
|
|
// The rule every game in this casino has had to be taught: the number the felt
|
|
// quotes and the number the settle lands on are one function, not two.
|
|
func TestQuoteIsThePayout(t *testing.T) {
|
|
for _, tier := range Tiers {
|
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
|
s.Tier = tier
|
|
s.Hands = make([][]Card, tier.Bots+1)
|
|
s.Hands[You] = []Card{{Red, Three}}
|
|
for i := 1; i <= tier.Bots; i++ {
|
|
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
|
|
}
|
|
quoted := s.Pays()
|
|
|
|
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
|
|
if err != nil {
|
|
t.Fatalf("%s: go out: %v", tier.Slug, err)
|
|
}
|
|
if next.Outcome != OutcomeWon {
|
|
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
|
|
}
|
|
if next.Payout != quoted {
|
|
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
|
|
}
|
|
if next.Net() != quoted-s.Bet {
|
|
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The rake comes out of the winnings, never the stake.
|
|
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
|
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
|
|
s.Bet = 100
|
|
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
|
if err != nil {
|
|
t.Fatalf("go out: %v", err)
|
|
}
|
|
if next.Payout != 214 {
|
|
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
|
|
}
|
|
if next.Rake != 6 {
|
|
t.Errorf("rake %d, want 6", next.Rake)
|
|
}
|
|
if next.Net() != 114 {
|
|
t.Errorf("net %d, want 114", next.Net())
|
|
}
|
|
}
|
|
|
|
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
|
// The bot holds one card that plays on the pile, so 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()
|
|
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
|
if err != nil {
|
|
t.Fatalf("play: %v", err)
|
|
}
|
|
if next.Outcome != OutcomeLost {
|
|
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
|
|
}
|
|
if next.Payout != 0 || next.Rake != 0 {
|
|
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
|
|
}
|
|
if next.Net() != -s.Bet {
|
|
t.Errorf("a loss costs the stake and no more: %d", next.Net())
|
|
}
|
|
last := evs[len(evs)-1]
|
|
if last.Kind != EvSettle || last.Seat != 1 {
|
|
t.Errorf("the settle should name the winner: %+v", last)
|
|
}
|
|
}
|
|
|
|
func TestNoMoveAfterItIsOver(t *testing.T) {
|
|
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
|
s.Tier = duel()
|
|
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
|
if err != nil {
|
|
t.Fatalf("go out: %v", err)
|
|
}
|
|
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
|
|
t.Fatalf("a finished game takes no more moves, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBadBet(t *testing.T) {
|
|
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
|
|
t.Fatalf("want ErrBadBet, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- the whole game -------------------------------------------------------
|
|
|
|
// playOut plays a game to its end with a simple strategy: play the first legal
|
|
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
|
|
func playOut(t *testing.T, s State, maxTurns int) State {
|
|
t.Helper()
|
|
for turn := 0; s.Phase != PhaseDone; turn++ {
|
|
if turn > maxTurns {
|
|
t.Fatalf("the game 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(); len(p) > 0 {
|
|
m = Move{Kind: MovePlay, Index: p[0]}
|
|
if s.Hands[You][p[0]].IsWild() {
|
|
m.Color = Green
|
|
}
|
|
} else if s.Phase == PhaseDrawn {
|
|
m = Move{Kind: MovePass}
|
|
} else {
|
|
m = Move{Kind: MoveDraw}
|
|
}
|
|
|
|
next, evs, err := ApplyMove(s, 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)
|
|
}
|
|
}
|
|
// No event ever names a bot's card. That is the hole card of this game, and
|
|
// it is most of the deck.
|
|
for _, e := range evs {
|
|
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
|
|
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
|
|
}
|
|
}
|
|
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 games, played out, with the invariants checked at every step. This
|
|
// is the test that would have caught a deck that leaks cards through the
|
|
// reshuffle, a turn the bots don't hand back, or a game that can't end.
|
|
func TestGamesPlayOut(t *testing.T) {
|
|
wins, losses, stuck := 0, 0, 0
|
|
for seed := uint64(0); seed < 100; seed++ {
|
|
tier := Tiers[seed%3]
|
|
end := playOut(t, deal(t, tier, 100, seed), 500)
|
|
switch end.Outcome {
|
|
case OutcomeWon:
|
|
wins++
|
|
if end.Payout != end.Pays() {
|
|
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
|
|
}
|
|
case OutcomeLost:
|
|
losses++
|
|
case OutcomeStuck:
|
|
stuck++
|
|
default:
|
|
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
|
|
}
|
|
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
|
|
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
|
}
|
|
}
|
|
// Playing the first legal card is a poor strategy against bots that hold their
|
|
// +4s back, so this is not a fairness assertion — it's a check that both
|
|
// outcomes actually happen. A table that never pays is a bug in the bots.
|
|
if wins == 0 || losses == 0 {
|
|
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
|
|
}
|
|
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
|
|
}
|
|
|
|
// winnerSeat is the seat the settle event named — only used by the tests.
|
|
func (s State) winnerSeat() int {
|
|
best := 0
|
|
for i := range s.Hands {
|
|
if len(s.Hands[i]) < len(s.Hands[best]) {
|
|
best = i
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// The same seed deals the same game and the bots make the same choices — which
|
|
// is what lets a disputed game be replayed exactly as it fell.
|
|
func TestReplaysFromTheSeed(t *testing.T) {
|
|
a := playOut(t, deal(t, full(), 250, 42), 500)
|
|
b := playOut(t, deal(t, full(), 250, 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.Outcome == "" {
|
|
t.Fatal("the replay didn't finish")
|
|
}
|
|
}
|
|
|
|
// A game in progress survives a redeploy: it is a plain value, so it round-trips
|
|
// through the JSON it is stored as.
|
|
func TestStateSurvivesStorage(t *testing.T) {
|
|
s := deal(t, table(), 100, 9)
|
|
s, _, err := ApplyMove(s, 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")
|
|
}
|
|
// And it plays on from there.
|
|
playOut(t, back, 500)
|
|
}
|
|
|
|
// A move the engine refuses leaves the caller's state exactly as it was — no
|
|
// card half-played, no turn half-passed.
|
|
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, 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 bot game, or the
|
|
// bot is a lookup table you can memorise.
|
|
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
|
same := 0
|
|
for seed := uint64(0); seed < 20; seed++ {
|
|
a := playOut(t, deal(t, duel(), 100, seed), 500)
|
|
b := playOut(t, deal(t, duel(), 100, 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")
|
|
}
|
|
}
|
|
|
|
// botPick holds its +4 back while it's comfortable, and reaches for it when
|
|
// somebody is about to go out.
|
|
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)
|
|
}
|
|
// A hand of nothing but wilds still has to name something.
|
|
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
|
|
}
|