aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/roulette_racetrack_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 17:50:33 -0400
committerhistoria <[not public]>2026-09-29 17:50:33 -0400
commitff9002b96e35ad0808945e7c35791460c9ca8776 (patch)
tree34c99c64176a7a2d26201f21f97ef7b54b64eb59 /internal/casino/roulette_racetrack_test.go
parent00fe5f7ead43627fa283035b27365924b76c5a0f (diff)
downloadthehouseoficarus-ff9002b96e35ad0808945e7c35791460c9ca8776.tar.gz
feat: roulette racetrack betting, rename casino to domita fortuna
Diffstat (limited to 'internal/casino/roulette_racetrack_test.go')
-rw-r--r--internal/casino/roulette_racetrack_test.go265
1 files changed, 265 insertions, 0 deletions
diff --git a/internal/casino/roulette_racetrack_test.go b/internal/casino/roulette_racetrack_test.go
new file mode 100644
index 0000000..7c465b8
--- /dev/null
+++ b/internal/casino/roulette_racetrack_test.go
@@ -0,0 +1,265 @@
+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)
+ }
+}