No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards, draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six tiers now; a normal game never runs a line of the new code. The engine is the whole of it so far — the felt hasn't been touched, so there is no way to play this in a browser yet. Two things worth knowing. The normal tiers were mispriced, and had been for a while. They were set against a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got better at some point after the multiples were written down and nobody re-ran the measurement — which the plan explicitly warns about, because the bots and the tiers are a pair. Table and Full House had been charging an 18–19% house edge instead of the 8% they were meant to. All six tiers are repriced off a fresh measurement, and TestTheMultiplesAreStillPriced now fails the build if they drift again. It is the test the normal tiers never had, which is how they drifted. And No Mercy is *easier* than UNO, at every table size, so it pays less. The mercy rule does not care whose hand hits twenty-five: it kills bots too, and every bot it buries is one fewer seat that can beat you to the last card. A deck built to be merciless turns out to be merciless mostly to the table. The rake test used to assert a payout of 214, which was the 2.2x duel written down as a number. It failed on a rake that was entirely correct. It derives the arithmetic from the tier now: the rule is that the house takes its cut of the profit and never touches the stake, and that holds at any multiple. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
806 lines
25 KiB
Go
806 lines
25 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. This asks the face, rather
|
||
// than listing the wilds: No Mercy prints four more of them, and a census
|
||
// that didn't know about them would count a played roulette as a red card
|
||
// and report a deck that balances while the cards don't.
|
||
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(), 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)
|
||
}
|
||
}
|
||
|
||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
|
||
// goes on the pile. Every seat can only pass, forever.
|
||
func dead(hands [][]Card) State {
|
||
s := rig(hands, Card{Red, Nine}, Red)
|
||
s.Deck = nil
|
||
return s
|
||
}
|
||
|
||
// The game has to end here. It used to not: the stuck guard counted how many
|
||
// bots had passed in a row and asked for more of them than there are seats, so
|
||
// it never fired once, and a dead table just handed the turn round and round.
|
||
// That is a game the player cannot finish — and a game they cannot finish is
|
||
// chips they cannot cash out, because the cage won't let them leave a live hand.
|
||
func TestDeadTableEnds(t *testing.T) {
|
||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
|
||
|
||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||
if err != nil {
|
||
t.Fatalf("draw: %v", err)
|
||
}
|
||
if next.Phase != PhaseDone {
|
||
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
|
||
}
|
||
if next.Outcome != OutcomeStuck {
|
||
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
|
||
}
|
||
if next.Payout != 0 {
|
||
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
|
||
}
|
||
if !hasKind(evs, EvSettle) {
|
||
t.Errorf("the table has to be told it's over: %+v", evs)
|
||
}
|
||
}
|
||
|
||
// And the shortest hand takes it, which is the one way a stuck table still pays.
|
||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||
|
||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||
if err != nil {
|
||
t.Fatalf("draw: %v", err)
|
||
}
|
||
if next.Outcome != OutcomeWon {
|
||
t.Fatalf("one card against two is a win: %q", next.Outcome)
|
||
}
|
||
if next.Payout != s.Pays() {
|
||
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
|
||
}
|
||
}
|
||
|
||
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.
|
||
//
|
||
// The arithmetic is derived from the tier rather than written down. It used to be
|
||
// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When
|
||
// the tiers were re-measured and repriced, this test failed on a rake that was
|
||
// perfectly correct, which is a test asserting a *price* while claiming to assert
|
||
// a *rule*. The rule is: the house takes its cut of the profit and never touches
|
||
// the stake. That holds at any multiple.
|
||
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||
s.Tier = duel()
|
||
s.Bet = 100
|
||
|
||
gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house
|
||
profit := gross - s.Bet
|
||
wantRake := int64(float64(profit) * rake)
|
||
wantPayout := s.Bet + profit - wantRake
|
||
|
||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||
if err != nil {
|
||
t.Fatalf("go out: %v", err)
|
||
}
|
||
if next.Payout != wantPayout {
|
||
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
|
||
next.Payout, wantPayout, s.Bet, profit, wantRake)
|
||
}
|
||
if next.Rake != wantRake {
|
||
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
|
||
next.Rake, wantRake, s.Bet)
|
||
}
|
||
if next.Net() != wantPayout-s.Bet {
|
||
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|