package casino import ( "fmt" "math/rand" "strings" "testing" "time" ) func crapsFixture() *crapsTable { return newCrapsTable(1, TableConfig{ID: "craps", Game: GameCraps, BettingTicks: 2}, rand.New(rand.NewSource(7))) } func TestCrapsAll36ComeOut(t *testing.T) { counts := map[string][3]int{} for _, kind := range []string{"pass", "dontpass", "come", "dontcome"} { for a := 1; a <= 6; a++ { for b := 1; b <= 6; b++ { done, x, _ := crapsOutcome(kind, 0, a, b, 0, 2) c := counts[kind] if done { if x > 0 { c[0]++ } else if x < 0 { c[1]++ } else { c[2]++ } } counts[kind] = c } } } if counts["pass"] != [3]int{8, 4, 0} || counts["come"] != counts["pass"] || counts["dontpass"] != [3]int{3, 8, 1} || counts["dontcome"] != counts["dontpass"] { t.Fatal(counts) } for _, n := range []int{4, 5, 6, 8, 9, 10} { for _, kind := range []string{"pass", "dontpass", "come", "dontcome"} { for a := 1; a <= 6; a++ { for b := 1; b <= 6; b++ { done, x, _ := crapsOutcome(kind, n, a, b, 6, 2) if done != (a+b == n || a+b == 7) { t.Fatal(kind, n, a, b) } if done && ((x > 0) != ((a+b == n) != strings.HasPrefix(kind, "dont"))) { t.Fatal("wrong winner", kind, n, a, b) } } } } } } func TestCrapsPayouts(t *testing.T) { cases := []struct { spot string amount, a, b, point, want int }{ {"field", 10, 6, 6, 6, 30}, {"field", 10, 1, 1, 6, 30}, {"field", 10, 3, 4, 6, 0}, {"place 4", 10, 2, 2, 6, 28}, {"place 5", 10, 2, 3, 6, 24}, {"place 6", 12, 3, 3, 6, 26}, {"buy 4", 20, 2, 2, 6, 59}, {"buy 5", 20, 2, 3, 6, 49}, {"buy 6", 20, 3, 3, 6, 43}, {"lay 4", 40, 3, 4, 6, 59}, {"lay 5", 30, 3, 4, 6, 49}, {"lay 6", 30, 3, 4, 6, 53}, {"hard 4", 10, 2, 2, 6, 80}, {"hard 6", 10, 3, 3, 6, 100}, {"hard 8", 10, 2, 6, 6, 0}, {"seven", 10, 3, 4, 6, 50}, {"craps", 10, 1, 1, 6, 80}, {"eleven", 10, 5, 6, 6, 160}, {"ace-deuce", 10, 1, 2, 6, 160}, {"aces", 10, 1, 1, 6, 310}, {"boxcars", 10, 6, 6, 6, 310}, {"hop 2 3", 10, 3, 2, 6, 160}, {"hop 3 3", 10, 3, 3, 6, 310}, {"horn", 4, 1, 1, 6, 31}, {"horn", 4, 5, 6, 6, 16}, } for _, c := range cases { t.Run(fmt.Sprintf("%s-%d%d", c.spot, c.a, c.b), func(t *testing.T) { tb := crapsFixture() tb.point = c.point w := &testWallet{chips: 10000} tb.join("a") tb.bet("a", c.spot, c.amount, w) if len(tb.players.get("a").bets) != 1 { t.Fatal("rejected") } tb.resolveRoll(c.a, c.b) if w.paid != c.want { t.Fatalf("paid %d want %d", w.paid, c.want) } if len(w.open) != 0 { t.Fatal("stake not consumed") } }) } tb := crapsFixture() tb.Config.Craps.Field12 = 3 w := &testWallet{chips: 100} tb.join("a") tb.bet("a", "field", 10, w) tb.resolveRoll(6, 6) if w.paid != 40 { t.Fatal(w.paid) } } func TestCrapsLineOddsAndQueue(t *testing.T) { tb := crapsFixture() w := &recordingWallet{testWallet: testWallet{chips: 1000}} tb.join("a") tb.join("b") tb.bet("a", "pass", 10, w) tb.resolveRoll(2, 2) tb.bet("a", "odds pass", 31, w) if len(w.open) != 1 { t.Fatal("over limit odds accepted") } tb.bet("a", "odds pass", 30, w) tb.resolveRoll(2, 2) if w.paid != 110 || len(w.payStakes) != 2 || tb.point != 0 || tb.queue[0] != "a" { t.Fatal(w.paid, w.payStakes, tb.point, tb.queue) } tb.join("c") tb.bet("a", "pass", 10, w) tb.resolveRoll(3, 3) tb.resolveRoll(3, 4) if strings.Join(tb.queue, ",") != "b,c,a" { t.Fatal(tb.queue) } tb.leave("c") tb.join("c") if strings.Join(tb.queue, ",") != "b,a,c" { t.Fatal(tb.queue) } } func TestCrapsComeTravelAndComeOutOddsOff(t *testing.T) { for _, win := range []bool{false, true} { tb := crapsFixture() tb.join("a") w := &testWallet{chips: 1000} tb.point = 4 tb.bet("a", "come", 10, w) tb.resolveRoll(3, 3) tb.bet("a", "odds come 6", 50, w) tb.resolveRoll(2, 2) if win { tb.resolveRoll(3, 3) if w.paid != 70 { t.Fatal("come odds should be returned, not win", w.paid) } } else { tb.resolveRoll(3, 4) if w.paid != 50 { t.Fatal("come odds should not lose", w.paid) } } if len(w.open) != 0 { t.Fatal(w.open) } } } func TestCrapsWorkingRules(t *testing.T) { for _, spot := range []string{"place 6", "buy 6", "hard 6"} { tb := crapsFixture() tb.join("a") w := &testWallet{chips: 100} tb.bet("a", spot, 30, w) tb.resolveRoll(3, 4) if w.paid != 0 || len(tb.players.get("a").bets) != 1 { t.Fatal(spot) } tb.resolveRoll(3, 3) if len(tb.players.get("a").bets) != 1 { t.Fatal("come-out hit must be off", spot) } tb.resolveRoll(3, 4) if len(tb.players.get("a").bets) != 0 { t.Fatal("seven out", spot) } } tb := crapsFixture() tb.join("a") w := &testWallet{chips: 100} tb.bet("a", "dontpass", 10, w) tb.resolveRoll(2, 2) tb.bet("a", "odds dontpass", 60, w) tb.resolveRoll(3, 4) if w.paid != 110 { t.Fatal(w.paid) } } func TestCrapsFireDistinctPoints(t *testing.T) { for count := 3; count <= 6; count++ { tb := crapsFixture() tb.join("a") w := &testWallet{chips: 100} tb.bet("a", "fire", 1, w) points := [][2]int{{2, 2}, {2, 3}, {3, 3}, {4, 4}, {4, 5}, {5, 5}} for _, dice := range points[:count] { tb.resolveRoll(dice[0], dice[1]) tb.resolveRoll(dice[0], dice[1]) } // Repeating an already made point must not increase Fire count. tb.resolveRoll(2, 2) tb.resolveRoll(2, 2) tb.resolveRoll(3, 3) tb.resolveRoll(3, 4) want := map[int]int{3: 0, 4: 25, 5: 250, 6: 1000}[count] if w.paid != want { t.Fatalf("%d points: %d want %d", count, w.paid, want) } } } func TestCrapsATSAndSevenReset(t *testing.T) { tb := crapsFixture() tb.join("a") w := &testWallet{chips: 100} for _, spot := range []string{"small", "tall", "all"} { tb.bet("a", spot, 1, w) } for _, d := range [][2]int{{1, 1}, {1, 2}, {2, 2}, {2, 3}, {3, 3}, {4, 4}, {4, 5}, {5, 5}, {5, 6}, {6, 6}} { tb.resolveRoll(d[0], d[1]) } if w.paid != 246 || len(w.open) != 0 { t.Fatal(w.paid, w.open) } tb = crapsFixture() tb.join("a") w = &testWallet{chips: 100} tb.bet("a", "small", 1, w) tb.resolveRoll(1, 1) tb.resolveRoll(3, 4) if w.paid != 0 || len(w.open) != 0 || len(tb.seen) != 0 { t.Fatal("ATS must lose on come-out seven") } tb.bet("a", "small", 1, w) if len(w.open) != 0 { t.Fatal("late bonus allowed") } } func TestCrapsTimersControlsAndRefunds(t *testing.T) { tb := crapsFixture() tb.join("a") tb.join("b") w := &testWallet{chips: 100} tb.bet("a", "pass", 10, w) tb.action("a", "roll") if tb.dice != [2]int{} { t.Fatal("early roll") } tb.tickElapsed(10 * time.Second) if tb.phase != phasePlaying { t.Fatal(tb.phase) } tb.action("b", "roll") if tb.dice != [2]int{} { t.Fatal("nonshooter roll") } tb.bet("a", "field", 10, w) if len(w.open) != 2 { t.Fatal("bets must remain open until the roll") } tb.action("a", "roll") if tb.dice == [2]int{} { t.Fatal("no roll") } tb = crapsFixture() tb.join("a") w = &testWallet{chips: 100} tb.bet("a", "pass", 10, w) tb.tickElapsed(120 * time.Second) if tb.dice != [2]int{} { t.Fatal("solo players must never be forced to roll") } tb.action("a", "roll") if tb.dice == [2]int{} { t.Fatal("solo manual roll stalled") } tb = crapsFixture() tb.join("a") w = &testWallet{chips: 100} tb.bet("a", "pass", 10, w) tb.resolveRoll(3, 3) tb.action("a", "unbet pass") if len(w.open) != 1 { t.Fatal("contract removed") } tb.markDisconnected("a") if len(w.open) != 0 || w.paid != 0 { t.Fatal("disconnect refunded") } tb = crapsFixture() tb.join("a") w = &testWallet{chips: 100} tb.bet("a", "pass", 10, w) tb.refundAll() tb.refundAll() if w.chips != 100 || len(w.open) != 0 { t.Fatal("refund not idempotent") } tb.bet("a", "place 6", 12, w) tb.action("a", "unbet place 6") if w.paid != 12 || tb.players.get("a").HasBet { t.Fatal("optional removal") } tb.bet("a", "pass", 10, w) tb.action("a", "leave_after") tb.resolveRoll(3, 4) if tb.hasParticipant("a") { t.Fatal("queued departure stuck") } } func TestCrapsValidation(t *testing.T) { for _, r := range []CrapsConfig{{Odds: -1}, {Field12: 4}, {Variant: "crapless"}} { if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err == nil { t.Fatal("accepted", r) } } for _, r := range []CrapsConfig{{}, {Odds: 10, Field12: 3}, {Odds: 100}} { if err := (TableConfig{ID: "c", Game: GameCraps, Craps: r}).Validate(); err != nil { t.Fatal(err) } } if err := (TableConfig{ID: "r", Game: GameRoulette, Craps: CrapsConfig{Field12: 3}}).Validate(); err == nil { t.Fatal("wrong rule block accepted") } tb := crapsFixture() tb.join("a") w := &testWallet{chips: 1000} for _, spot := range []string{"place 7", "hard 5", "hop 0 6", "horn", "place 6", "come", "odds pass", "unknown"} { tb.bet("a", spot, 1, w) } if len(w.open) != 0 { t.Fatal("invalid bets debited", w.open) } } func TestCrapsOddsLimitsAndReturns(t *testing.T) { for _, uniform := range []int{0, 2, 10} { for _, n := range []int{4, 5, 6, 8, 9, 10} { for _, against := range []bool{false, true} { tb := crapsFixture() tb.Config.Craps.Odds = uniform tb.join("a") w := &testWallet{chips: 10000} kind := "pass" if against { kind = "dontpass" } tb.bet("a", kind, 10, w) a, b := n/2, n-n/2 tb.resolveRoll(a, b) limit := tb.Config.Craps.oddsLimit(n, against) * 10 x, y := crapsTrueOdds(n) if against { x, y = y, x } amount := limit - limit%y tb.bet("a", "odds "+kind, limit+y, w) if len(w.open) != 1 { t.Fatal("overlimit", uniform, n, against) } tb.bet("a", "odds "+kind, amount, w) if len(w.open) != 2 { t.Fatal("odds rejected", uniform, n, against) } if against { tb.resolveRoll(3, 4) } else { tb.resolveRoll(a, b) } if w.paid != 20+amount+amount/y*x { t.Fatal("wrong odds", uniform, n, against, w.paid) } } } } } func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) { tb := crapsFixture() tb.join("a") w := &failingPayoutWallet{testWallet: testWallet{chips: 100}} tb.bet("a", "pass", 10, w) snap := tb.view() snap.Queue[0] = "changed" snap.Bets[0].Amount = 999 if tb.queue[0] != "a" || tb.players.get("a").bets[0].stake.Total != 10 { t.Fatal("snapshot aliases live state") } events := tb.resolveRoll(3, 4) found := false for _, e := range events { if strings.Contains(e.Message, payoutFailedMessage()) { found = true } } if !found || len(w.open) != 1 || w.paid != 0 || tb.players.get("a").HasBet { t.Fatal("failed payout handling", events, w) } tb.refundAll() tb.refundAll() if w.chips != 100 || len(w.open) != 0 { t.Fatal("pending recovery", w) } tb = crapsFixture() tb.join("a") good := &testWallet{chips: 100} tb.bet("a", "place 6", 12, good) tb.action("a", "leave_after") tb.action("a", "unbet place 6") if tb.hasParticipant("a") { t.Fatal("unbet stranded queued departure") } }