aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/craps_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/craps_test.go')
-rw-r--r--internal/casino/craps_test.go403
1 files changed, 403 insertions, 0 deletions
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
new file mode 100644
index 0000000..5c7b0df
--- /dev/null
+++ b/internal/casino/craps_test.go
@@ -0,0 +1,403 @@
+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")
+ }
+}