package casino import ( "fmt" "math/rand" "strconv" "strings" "testing" ) func TestRouletteRulesAndWheels(t *testing.T) { for _, double := range []bool{false, true} { wheel := rouletteWheel(double) seen := map[string]bool{} want := 37 if double { want = 38 } if len(wheel) != want { t.Fatal("wheel size", len(wheel)) } for _, pocket := range wheel { if seen[pocket] { t.Fatal("duplicate", pocket) } seen[pocket] = true } for n := 0; n <= 36; n++ { if !seen[strconv.Itoa(n)] { t.Fatal("missing", n) } } if seen["00"] != double { t.Fatal("wrong double zero") } for _, spot := range strings.Fields("red black even odd low high dozen1 dozen2 dozen3 column1 column2 column3 1to12 13to24 25to36 row1 row2 row3") { pockets, mult := rouletteBet(spot, double) if len(pockets)*mult != 36 { t.Fatalf("%s coverage/payout %d/%d", spot, len(pockets), mult) } for _, p := range pockets { if p == "0" || p == "00" { t.Fatal("zero in outside bet") } } } pockets, mult := rouletteBet("00", double) if double && (len(pockets) != 1 || mult != 36) || !double && mult != 0 { t.Fatal("00 rules") } } for _, spot := range []string{"-1", "37", "000", "01", "banana", ""} { if _, m := rouletteBet(spot, true); m != 0 { t.Fatal("accepted", spot) } } } func TestRouletteRowBetMapping(t *testing.T) { // row1 is the top numbered line (3,6,...36) and row3 the bottom (1,4,...34). row1, mult := rouletteBet("row1", false) if mult != 3 || len(row1) != 12 || row1[0] != "3" || row1[len(row1)-1] != "36" { t.Fatalf("row1 = %v (x%d), want 3..36", row1, mult) } row3, mult := rouletteBet("row3", false) if mult != 3 || len(row3) != 12 || row3[0] != "1" || row3[len(row3)-1] != "34" { t.Fatalf("row3 = %v (x%d), want 1..34", row3, mult) } } // TestRouletteCombinationBets covers splits, streets, corners, six-lines, // trios and the basket, including the geometry that must be rejected and the // bare-number-list inference that lets the leading keyword be omitted. func TestRouletteCombinationBets(t *testing.T) { same := func(got []string, want ...string) bool { if len(got) != len(want) { return false } set := map[string]bool{} for _, g := range got { set[g] = true } for _, w := range want { if !set[w] { return false } } return true } cases := []struct { spot string double bool mult int want []string }{ // Splits cover edge-neighbours only, horizontally or vertically. {"split 1 2", false, 18, []string{"1", "2"}}, {"1-2", false, 18, []string{"1", "2"}}, {"1/4", false, 18, []string{"1", "4"}}, {"split 1 5", false, 0, nil}, {"split 3 4", false, 0, nil}, {"split 0 3", false, 18, []string{"0", "3"}}, // Streets are one three-row column. {"street 1 2 3", false, 12, []string{"1", "2", "3"}}, {"1-2-3", false, 12, []string{"1", "2", "3"}}, {"street 2 3 4", false, 0, nil}, // Corners are a two-by-two block. {"corner 1 2 4 5", false, 9, []string{"1", "2", "4", "5"}}, {"1-2-4-5", false, 9, []string{"1", "2", "4", "5"}}, {"corner 1 2 3 4", false, 0, nil}, // Six-lines are two adjacent streets. {"line 1 2 3 4 5 6", false, 6, []string{"1", "2", "3", "4", "5", "6"}}, {"1-2-3-4-5-6", false, 6, []string{"1", "2", "3", "4", "5", "6"}}, {"line 1 2 3 4 5 7", false, 0, nil}, // Trios pair a zero with the first street. {"trio 0 1 2", false, 12, []string{"0", "1", "2"}}, {"0-1-2", false, 12, []string{"0", "1", "2"}}, {"trio 0 00 2", true, 12, []string{"0", "00", "2"}}, {"trio 0 00 2", false, 0, nil}, // The American basket is five numbers at a deliberately short 6:1. {"basket", true, 7, []string{"0", "00", "1", "2", "3"}}, {"basket 0 00 1 2 3", true, 7, []string{"0", "00", "1", "2", "3"}}, {"basket 0 00 1 2", true, 0, nil}, // Single zero falls back to the European first four. {"basket", false, 9, []string{"0", "1", "2", "3"}}, {"corner 0 1 2 3", false, 9, []string{"0", "1", "2", "3"}}, // Zero-to-zero is an American split only. {"split 0 00", true, 18, []string{"0", "00"}}, {"split 0 00", false, 0, nil}, } for _, tc := range cases { got, mult := rouletteBet(tc.spot, tc.double) if mult != tc.mult { t.Fatalf("%q double=%v: mult=%d, want %d", tc.spot, tc.double, mult, tc.mult) } if tc.mult == 0 { continue } if !same(got, tc.want...) { t.Fatalf("%q double=%v: pockets=%v, want %v", tc.spot, tc.double, got, tc.want) } // Every fair combination covers a divisor of 36; the American basket // is the one intentional exception (5 x 7 = 35, not 36). if tc.mult != 7 && len(got)*mult != 36 { t.Fatalf("%q: coverage/payout %d/%d", tc.spot, len(got), mult) } } // A bare list and its named form must reach the same bet. for _, pair := range [][2]string{{"1-2", "split 1 2"}, {"1-2-3", "street 1 2 3"}, {"1-2-4-5", "corner 1 2 4 5"}, {"1-2-3-4-5-6", "line 1 2 3 4 5 6"}} { bare, bareMult := rouletteBet(pair[0], false) named, namedMult := rouletteBet(pair[1], false) if bareMult != namedMult || !same(bare, named...) { t.Fatalf("inference %q != %q: %v/%d vs %v/%d", pair[0], pair[1], bare, bareMult, named, namedMult) } } } // TestRouletteCombinationOrderIndependent checks that every permutation of a // combination's numbers selects the same bet. Street 3-1-2 must be the 1-2-3 // street, and so on for splits, corners, lines, trios and the basket. The // payout multiplier and the covered pockets must not depend on input order. func TestRouletteCombinationOrderIndependent(t *testing.T) { same := func(a, b []string) bool { if len(a) != len(b) { return false } set := map[string]bool{} for _, x := range a { set[x] = true } for _, x := range b { if !set[x] { return false } } return true } var permute func([]string, func([]string)) permute = func(items []string, visit func([]string)) { var rec func(int) rec = func(i int) { if i == len(items) { visit(append([]string(nil), items...)) return } for j := i; j < len(items); j++ { items[i], items[j] = items[j], items[i] rec(i + 1) items[i], items[j] = items[j], items[i] } } rec(0) } cases := []struct { kind string nums []string double bool }{ {"split", []string{"1", "2"}, false}, {"split", []string{"0", "00"}, true}, {"street", []string{"1", "2", "3"}, false}, {"corner", []string{"1", "2", "4", "5"}, false}, {"line", []string{"1", "2", "3", "4", "5", "6"}, false}, {"trio", []string{"0", "1", "2"}, false}, {"trio", []string{"0", "00", "2"}, true}, {"basket", []string{"0", "00", "1", "2", "3"}, true}, } for _, tc := range cases { canonical := tc.kind + " " + strings.Join(tc.nums, " ") wantPockets, wantMult := rouletteBet(canonical, tc.double) if wantMult == 0 { t.Fatalf("canonical %q is invalid", canonical) } permute(append([]string(nil), tc.nums...), func(p []string) { spot := tc.kind + " " + strings.Join(p, " ") got, mult := rouletteBet(spot, tc.double) if mult != wantMult || !same(got, wantPockets) { t.Fatalf("%q: got %v/%d, want %v/%d", spot, got, mult, wantPockets, wantMult) } }) } } // TestRouletteCombinationSettlement drives a split through the real table // phases, so coverage and payout are exercised through settle, not only by // calling rouletteBet directly. func TestRouletteCombinationSettlement(t *testing.T) { table := rouletteFixture() wheel := rouletteWheel(false) index := rand.New(rand.NewSource(7)).Intn(len(wheel)) pocket := wheel[index] // Pick a real split covering the sampled pocket. split := "" for n := 1; n <= 36 && split == ""; n++ { for _, m := range []int{n + 1, n + 3} { if m > 36 { continue } spot := fmt.Sprintf("split %d %d", n, m) covered, mult := rouletteBet(spot, false) if mult == 0 { continue } for _, p := range covered { if p == pocket { split = spot break } } if split != "" { break } } } if split == "" { split = "split 0 1" if _, mult := rouletteBet(split, false); mult == 0 { t.Fatal("no split covers pocket ", pocket) } } table.join("a") w := &testWallet{chips: 100} table.bet("a", split, 10, w) for i := 0; i < 4; i++ { table.tick() } if w.paid != 180 || len(w.open) != 0 { t.Fatalf("split %q pocket %s: wallet %+v", split, pocket, w) } } func rouletteFixture() *rouletteTable { return newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{SpinTicks: 2}}, rand.New(rand.NewSource(7))) } func TestRouletteTimedPhasesAndSettlement(t *testing.T) { for _, double := range []bool{false, true} { table := rouletteFixture() table.Config.Roulette.DoubleZero = double wheel := rouletteWheel(double) index := rand.New(rand.NewSource(7)).Intn(len(wheel)) table.join("a") w := &testWallet{chips: 100} table.bet("a", wheel[index], 10, w) table.bet("a", "red", 10, w) if w.chips != 90 || table.phase != phaseBetting { t.Fatal("duplicate or early spin") } table.tick() if table.phase != phaseBetting { t.Fatal("betting ended early") } events := table.tick() if table.phase != phasePlaying || len(events) == 0 { t.Fatal("no spinning phase") } table.join("b") other := &testWallet{chips: 100} table.bet("b", "red", 10, other) if other.chips != 100 { t.Fatal("bet while spinning") } table.tick() if w.paid != 0 { t.Fatal("settled early") } table.action("a", "leave_after") events = table.tick() if w.paid != 360 || len(w.open) != 0 || table.hasParticipant("a") { t.Fatalf("bad settlement: %+v", w) } s := events[0].Snapshot.(*RouletteSnapshot) if s.Pockets != [3]string{wheel[(index+len(wheel)-1)%len(wheel)], wheel[index], wheel[(index+1)%len(wheel)]} { t.Fatal("incorrect wheel neighbors") } table.tick() table.refundAll() if w.paid != 360 { t.Fatal("double paid") } } } func TestRouletteRefundAndPersistenceFailure(t *testing.T) { for _, spinning := range []bool{false, true} { table := rouletteFixture() table.join("a") w := &testWallet{chips: 100} table.bet("a", "red", 10, w) if spinning { table.tick() table.tick() } table.markDisconnected("a") table.refundAll() table.tick() if w.chips != 100 || len(w.open) != 0 || w.paid != 0 { t.Fatalf("disconnect refund: %+v", w) } } table := rouletteFixture() table.join("a") w := &failingPayoutWallet{testWallet: testWallet{chips: 100}} table.bet("a", "red", 10, w) for i := 0; i < 4; i++ { table.tick() } if len(w.open) != 1 { t.Fatal("failed settlement consumed stake") } table.refundAll() table.refundAll() if w.chips != 100 || len(w.open) != 0 { t.Fatal("failed settlement not refunded once") } } func TestRouletteConfig(t *testing.T) { cfg := TableConfig{ID: "r", Game: GameRoulette} if err := cfg.Validate(); err != nil { t.Fatal(err) } cfg.Roulette.SpinTicks = -1 if cfg.Validate() == nil { t.Fatal("invalid ticks accepted") } cfg.Roulette.SpinTicks = 2 cfg.Game = GameBlackjack if cfg.Validate() == nil { t.Fatal("foreign rule block accepted") } }