package casino import ( "math/rand" "strings" "testing" ) // TestRouletteCallBetMath checks the four French call bets against the // traditional chip placements: each component returns exactly 36/covered, so // the whole wager keeps the wheel's normal house edge. func TestRouletteCallBetMath(t *testing.T) { if len(rouletteCallBets) != 4 { t.Fatalf("want four call bets, got %d", len(rouletteCallBets)) } for _, bet := range rouletteCallBets { // The display series must be exactly the pockets the chips cover. covered := map[string]bool{} for _, part := range bet.parts { for _, p := range part.pockets { covered[p] = true } } if len(bet.series) != len(covered) { t.Fatalf("%s: series %d pockets, chips cover %d", bet.name, len(bet.series), len(covered)) } for _, p := range bet.series { if !covered[p] { t.Fatalf("%s: series lists %s which no chip covers", bet.name, p) } } // Chips and shares are internally consistent. chips := 0 for _, part := range bet.parts { chips += part.chips } if chips != bet.chips { t.Fatalf("%s: parts total %d chips, want %d", bet.name, chips, bet.chips) } wager := bet.wager() den := 0 sum := 0 for _, share := range wager.shares { if den == 0 { den = share.den } if share.den != den { t.Fatalf("%s: mixed share denominators %d and %d", bet.name, den, share.den) } sum += share.num } if sum != 36*den { t.Fatalf("%s: returns sum to %d/%d, want 36", bet.name, sum, den) } if got := len(wager.shares); got != len(covered) { t.Fatalf("%s: wager covers %d pockets, want %d", bet.name, got, len(covered)) } } } // TestRouletteCallBetAliases checks that the shorthand and full names reach the // same wager, and that they require a racetrack single-zero table. func TestRouletteCallBetAliases(t *testing.T) { for _, tc := range []struct { syntax []string name string }{ {[]string{"voisins", "voisins du zero", "Voisins du Zéro", "vdz", "grands voisins du zero"}, "voisins"}, {[]string{"tiers", "tiers du cylindre", "le tiers du cylindre", "tdc"}, "tiers"}, {[]string{"orphelins", "orphans", "les orphelins"}, "orphelins"}, {[]string{"zero spiel", "zerospiel", "spiel", "jeu zero", "zero game"}, "zerospiel"}, } { var want rouletteWager for _, bet := range rouletteCallBets { if bet.canonical == tc.name { want = bet.wager() } } for _, syntax := range tc.syntax { got, ok := rouletteWagerFor(syntax, false, true) if !ok { t.Fatalf("racetrack %q not resolved", syntax) } if len(got.shares) != len(want.shares) { t.Fatalf("%q: %d pockets, want %d", syntax, len(got.shares), len(want.shares)) } for pocket, share := range want.shares { if got.shares[pocket] != share { t.Fatalf("%q pocket %s: %s, want %s", syntax, pocket, formatShare(got.shares[pocket]), formatShare(share)) } } // The same name on a table without the racetrack option is unknown. if _, ok := rouletteWagerFor(syntax, false, false); ok { t.Fatalf("%q resolved with racetrack disabled", syntax) } // Double-zero tables never offer the call bets. if _, ok := rouletteWagerFor(syntax, true, true); ok { t.Fatalf("%q resolved on a double-zero table", syntax) } } } } // TestRouletteCallBetPayouts pins the per-pocket returns, including the // Orphelins number covered by two splits and the floor on non-divisible // stakes. func TestRouletteCallBetPayouts(t *testing.T) { wager := func(syntax string) rouletteWager { w, ok := rouletteWagerFor(syntax, false, true) if !ok { t.Fatalf("cannot resolve %q", syntax) } return w } payout := func(w rouletteWager, pocket string, stake int) int { share, ok := w.covers(pocket) if !ok { t.Fatalf("pocket %s not covered", pocket) } got, err := share.payout(stake) if err != nil { t.Fatal(err) } return got } // Voisins: the 0/2/3 trio returns 24/9 of the stake, splits 18/9. voisins := wager("voisins") if got := payout(voisins, "0", 9); got != 24 { t.Fatalf("voisins 9 on 0 = %d, want 24", got) } if got := payout(voisins, "3", 9); got != 24 { t.Fatalf("voisins 9 on 3 = %d, want 24", got) } if got := payout(voisins, "4", 9); got != 18 { t.Fatalf("voisins 9 on 4 = %d, want 18", got) } if got := payout(voisins, "25", 9); got != 18 { t.Fatalf("voisins 9 on 25 = %d, want 18", got) } // A stake the chip count does not divide floors, never rounds up. if got := payout(voisins, "0", 10); got != 26 { t.Fatalf("voisins 10 on 0 = %d, want 26", got) } // Orphelins: 1 and 17 (two winning splits) pay 36/5, the rest 18/5. orphelins := wager("orphelins") if got := payout(orphelins, "1", 5); got != 36 { t.Fatalf("orphelins 5 on 1 = %d, want 36", got) } if got := payout(orphelins, "17", 5); got != 36 { t.Fatalf("orphelins 5 on 17 = %d, want 36", got) } if got := payout(orphelins, "6", 5); got != 18 { t.Fatalf("orphelins 5 on 6 = %d, want 18", got) } // Zero Spiel: 26 is the straight-up. spiel := wager("zero spiel") if got := payout(spiel, "26", 4); got != 36 { t.Fatalf("zero spiel 4 on 26 = %d, want 36", got) } if got := payout(spiel, "0", 4); got != 18 { t.Fatalf("zero spiel 4 on 0 = %d, want 18", got) } // Tiers is a plain six-way split. tiers := wager("tiers") if got := payout(tiers, "5", 6); got != 18 { t.Fatalf("tiers 6 on 5 = %d, want 18", got) } } // TestRouletteNeighbors checks the neighbours bet covers the chosen number and // four each side in wheel order, one chip on each, and rejects malformed // targets. func TestRouletteNeighbors(t *testing.T) { wheel := rouletteWheel(false) if len(wheel) != 37 { t.Fatal("unexpected wheel size") } index := 0 for i, p := range wheel { if p == "17" { index = i } } for _, syntax := range []string{"neighbors 17", "neighbors of 17", "neighbours 17", "17 neighbors"} { wager, ok := rouletteWagerFor(syntax, false, true) if !ok { t.Fatalf("%q not resolved", syntax) } if len(wager.shares) != 9 { t.Fatalf("%q covers %d pockets, want 9", syntax, len(wager.shares)) } for d := -neighborsRadius; d <= neighborsRadius; d++ { want := wheel[(index+d+len(wheel))%len(wheel)] share, ok := wager.covers(want) if !ok { t.Fatalf("%q missing wheel neighbour %s", syntax, want) } if share != (rouletteShare{num: 36, den: 9}) { t.Fatalf("%q pocket %s share %s, want 36/9", syntax, want, formatShare(share)) } } } for _, syntax := range []string{"neighbors", "neighbors 37", "neighbors 1 2", "neighbors 00"} { if _, ok := rouletteWagerFor(syntax, false, true); ok { t.Fatalf("%q should not resolve", syntax) } } // Neighbours needs the racetrack option and a single-zero wheel. if _, ok := rouletteWagerFor("neighbors 17", false, false); ok { t.Fatal("neighbors resolved with racetrack disabled") } if _, ok := rouletteWagerFor("neighbors 17", true, true); ok { t.Fatal("neighbors resolved on a double-zero table") } // A bare number is still a straight-up, not a neighbours bet. if _, ok := rouletteWagerFor("17", false, true); !ok { t.Fatal("straight 17 no longer resolves") } } // TestRouletteRacetrackSettlement drives a call bet through the real table // phases and checks the exact per-pocket payout for the sampled winner. func TestRouletteRacetrackSettlement(t *testing.T) { table := newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{Racetrack: true, SpinTicks: 2}}, rand.New(rand.NewSource(7))) wheel := rouletteWheel(false) index := rand.New(rand.NewSource(7)).Intn(len(wheel)) pocket := wheel[index] // Voisins, Tiers and Orphelins partition the wheel, so one covers it. syntax := "" var share rouletteShare for _, bet := range rouletteCallBets { if s, ok := bet.wager().covers(pocket); ok { syntax, share = bet.canonical, s break } } if syntax == "" { t.Fatal("no call bet covers pocket ", pocket) } table.join("a") w := &testWallet{chips: 100} table.bet("a", syntax, 10, w) for i := 0; i < 4; i++ { table.tick() } want, err := share.payout(10) if err != nil { t.Fatal(err) } if w.paid != want || len(w.open) != 0 { t.Fatalf("%q pocket %s: paid %d, want %d (%+v)", syntax, pocket, w.paid, want, w) } } // TestRouletteRacetrackConfig rejects a racetrack on the American wheel. func TestRouletteRacetrackConfig(t *testing.T) { cfg := TableConfig{ID: "r", Game: GameRoulette, Roulette: RouletteConfig{Racetrack: true}} if err := cfg.Validate(); err != nil { t.Fatalf("single-zero racetrack rejected: %v", err) } cfg.Roulette.DoubleZero = true if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "single-zero") { t.Fatalf("double-zero racetrack accepted: %v", err) } }