diff options
| author | historia <[not public]> | 2026-09-29 17:50:33 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 17:50:33 -0400 |
| commit | ff9002b96e35ad0808945e7c35791460c9ca8776 (patch) | |
| tree | 34c99c64176a7a2d26201f21f97ef7b54b64eb59 /internal/casino/roulette_racetrack_test.go | |
| parent | 00fe5f7ead43627fa283035b27365924b76c5a0f (diff) | |
| download | thehouseoficarus-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.go | 265 |
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) + } +} |
