aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/roulette.go71
-rw-r--r--internal/casino/roulette_geometry_test.go109
-rw-r--r--internal/casino/roulette_racetrack.go18
-rw-r--r--internal/casino/roulette_racetrack_test.go34
-rw-r--r--internal/casino/table_ux.go4
5 files changed, 169 insertions, 67 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 252ac4c..109306a 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -306,19 +306,17 @@ func rouletteAdjacent(a, b string, double bool) bool {
return (dc == 1 && dr == 0) || (dc == 0 && dr == 1)
}
-// The zero lane spans all three numbered rows, so 0 splits with 1, 2 and 3.
-// On an American felt the two zeros are adjacent to each other as well.
+// European zero touches 1, 2 and 3. On the American layout, 0
+// touches 1 and 2; 00 touches 2 and 3. The zeros also touch each other.
func rouletteZeroAdjacent(a, b string, double bool) bool {
- neighbors := func(other string) bool {
- if other == "0" || other == "00" {
- return double
- }
- return other == "1" || other == "2" || other == "3"
+ if b == "0" || b == "00" {
+ a, b = b, a
}
- if a == "0" || a == "00" {
- return neighbors(b)
+ if !double {
+ return a == "0" && (b == "1" || b == "2" || b == "3")
}
- return neighbors(a)
+ return (a == "0" && (b == "00" || b == "1" || b == "2")) ||
+ (a == "00" && (b == "0" || b == "2" || b == "3"))
}
func rouletteIsStreet(nums []string) bool {
@@ -352,6 +350,7 @@ func rouletteIsCorner(nums []string, double bool) bool {
if !double && rouletteIsFirstFour(nums) {
return true
}
+ minCol, maxCol, minRow, maxRow := 12, -1, 3, -1
cells := map[[2]int]bool{}
for _, s := range nums {
c, r, ok := rouletteNumberCell(s)
@@ -359,26 +358,10 @@ func rouletteIsCorner(nums []string, double bool) bool {
return false
}
cells[[2]int{c, r}] = true
+ minCol, maxCol = min(minCol, c), max(maxCol, c)
+ minRow, maxRow = min(minRow, r), max(maxRow, r)
}
- if len(cells) != 4 {
- return false
- }
- cols, rows := map[int]bool{}, map[int]bool{}
- for cell := range cells {
- cols[cell[0]] = true
- rows[cell[1]] = true
- }
- if len(cols) != 2 || len(rows) != 2 {
- return false
- }
- for c := range cols {
- for r := range rows {
- if !cells[[2]int{c, r}] {
- return false
- }
- }
- }
- return true
+ return len(cells) == 4 && maxCol-minCol == 1 && maxRow-minRow == 1
}
func rouletteIsFirstFour(nums []string) bool {
@@ -427,30 +410,22 @@ func rouletteIsLine(nums []string) bool {
return c2-c1 == 1
}
-// A trio is any three distinct pockets drawn from 0, 00 and the first street
-// (1,2,3) that includes at least one zero. It pays the same 11:1 as a street.
+// Trios cover touching zero-edge intersections, not arbitrary selections
+// from the zeros and the first street.
func rouletteIsTrio(nums []string, double bool) bool {
if len(nums) != 3 {
return false
}
- hasZero := false
- for _, s := range nums {
- switch s {
- case "0":
- hasZero = true
- case "00":
- if !double {
- return false
- }
- hasZero = true
- default:
- v, err := strconv.Atoi(s)
- if err != nil || v < 1 || v > 3 {
- return false
- }
- }
+ set := map[string]bool{}
+ for _, n := range nums {
+ set[n] = true
+ }
+ if double {
+ return (set["0"] && set["1"] && set["2"]) ||
+ (set["0"] && set["00"] && set["2"]) ||
+ (set["00"] && set["2"] && set["3"])
}
- return hasZero
+ return set["0"] && set["2"] && (set["1"] || set["3"])
}
// The basket is the American five-number 0/00/1/2/3 bet, which pays a short
diff --git a/internal/casino/roulette_geometry_test.go b/internal/casino/roulette_geometry_test.go
new file mode 100644
index 0000000..37fec74
--- /dev/null
+++ b/internal/casino/roulette_geometry_test.go
@@ -0,0 +1,109 @@
+package casino
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestRouletteRejectsNonTouchingCombinations(t *testing.T) {
+ for _, tc := range []struct {
+ target string
+ double bool
+ }{
+ {"corner 1 3 4 6", false}, {"corner 1 2 7 8", false},
+ {"corner 1 3 34 36", true}, {"trio 0 1 3", false},
+ {"trio 0 1 3", true}, {"trio 0 2 3", true},
+ {"trio 00 1 2", true}, {"trio 0 00 1", true},
+ {"trio 0 00 3", true}, {"split 0 3", true},
+ {"split 00 1", true},
+ } {
+ if _, mult := rouletteBet(tc.target, tc.double); mult != 0 {
+ t.Errorf("accepted %q (double=%v)", tc.target, tc.double)
+ }
+ }
+ for _, double := range []bool{false, true} {
+ for c := 0; c < 11; c++ {
+ for r := 1; r <= 2; r++ {
+ n := 3*c + r
+ target := fmt.Sprintf("corner %d %d %d %d", n, n+1, n+3, n+4)
+ if _, mult := rouletteBet(target, double); mult != 9 {
+ t.Errorf("rejected %s", target)
+ }
+ }
+ }
+ for _, target := range []string{"split 0 1", "split 0 2", "trio 0 1 2"} {
+ if _, mult := rouletteBet(target, double); mult == 0 {
+ t.Errorf("rejected %s", target)
+ }
+ }
+ }
+ for _, target := range []string{"split 00 2", "split 00 3", "trio 00 2 3", "trio 0 00 2"} {
+ if _, mult := rouletteBet(target, true); mult == 0 {
+ t.Errorf("rejected %s", target)
+ }
+ }
+}
+
+// Sum the return over every equally likely outcome. Ordinary bets return
+// 36 stakes per wheel; the American five-number basket returns only 35.
+func TestRouletteWheelAndExpectedReturns(t *testing.T) {
+ for _, double := range []bool{false, true} {
+ wheel := WheelOrder(double)
+ want := 37
+ if double {
+ want = 38
+ }
+ seen := map[string]bool{}
+ for _, p := range wheel {
+ if _, valid := roulettePocketToken(p, double); !valid || seen[p] {
+ t.Fatalf("invalid wheel pocket %s", p)
+ }
+ seen[p] = true
+ }
+ if len(seen) != want {
+ t.Fatalf("wheel has %d pockets, want %d", len(seen), want)
+ }
+ targets := append(WheelOrder(double), "red", "black", "odd", "even", "low", "high", "1to12", "13to24", "25to36", "row1", "row2", "row3", "split 1 2", "street 1 2 3", "corner 1 2 4 5", "line 1 2 3 4 5 6", "trio 0 1 2", "basket")
+ for _, target := range targets {
+ wager, ok := rouletteWagerFor(target, double, false)
+ if !ok {
+ t.Fatalf("invalid target %s", target)
+ }
+ sum := 0
+ for _, p := range wheel {
+ share, _ := wager.covers(p)
+ payout, err := share.payout(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum += payout
+ }
+ wantReturn := 36
+ if double && target == "basket" {
+ wantReturn = 35
+ }
+ if sum != wantReturn {
+ t.Errorf("%s: total return %d, want %d", target, sum, wantReturn)
+ }
+ }
+ }
+}
+
+func TestRouletteSharePayoutCap(t *testing.T) {
+ shares := []rouletteShare{{36, 1}, {7, 1}, {36, 9}}
+ for _, bet := range rouletteCallBets {
+ for _, share := range bet.wager().shares {
+ shares = append(shares, share)
+ }
+ }
+ for _, share := range shares {
+ limit := ((MaxCredits+1)*share.den - 1) / share.num
+ got, err := share.payout(limit)
+ if err != nil || got > MaxCredits {
+ t.Fatalf("%+v at limit: %d, %v", share, got, err)
+ }
+ if _, err := share.payout(limit + 1); err == nil {
+ t.Fatalf("%+v accepted overflow", share)
+ }
+ }
+}
diff --git a/internal/casino/roulette_racetrack.go b/internal/casino/roulette_racetrack.go
index 624940b..9784d9a 100644
--- a/internal/casino/roulette_racetrack.go
+++ b/internal/casino/roulette_racetrack.go
@@ -23,7 +23,7 @@ func (s rouletteShare) payout(stake int) (int, error) {
if stake <= 0 || s.num <= 0 || s.den <= 0 {
return 0, nil
}
- if limit := MaxCredits * s.den / s.num; stake > limit {
+ if limit := ((MaxCredits+1)*s.den - 1) / s.num; stake > limit {
return MaxCredits, fmt.Errorf("payout overflow: %d * %d/%d exceeds %d", stake, s.num, s.den, MaxCredits)
}
return stake * s.num / s.den, nil
@@ -96,8 +96,8 @@ func RacetrackBets() []RacetrackBetView {
// The four standard single-zero call bets. Chip placements and payouts follow
// the traditional layout: Voisins (9 chips), Tiers (6), Orphelins (5) and
-// Zero Spiel (4). Every inside component returns 36/covered, so the combined
-// wager keeps the wheel's normal house edge.
+// Jeu Zero (4). Every inside component returns 36/covered, so the combined
+// wager keeps the wheel's normal house edge before whole-credit rounding.
var rouletteCallBets = []rouletteCallBet{
{
canonical: "voisins",
@@ -142,8 +142,8 @@ var rouletteCallBets = []rouletteCallBet{
},
},
{
- canonical: "zerospiel",
- name: "Zero Spiel",
+ canonical: "jeuzero",
+ name: "Jeu Zero",
chips: 4,
series: []string{"12", "35", "3", "26", "0", "32", "15"},
parts: []rouletteComponent{
@@ -171,11 +171,9 @@ var rouletteCallAliases = map[string]string{
"orphelins": "orphelins",
"orphans": "orphelins",
"les orphelins": "orphelins",
- "zero spiel": "zerospiel",
- "zerospiel": "zerospiel",
- "spiel": "zerospiel",
- "jeu zero": "zerospiel",
- "zero game": "zerospiel",
+ "zero": "jeuzero",
+ "jeu zero": "jeuzero",
+ "jeuzero": "jeuzero",
}
// neighborsRadius is how many pockets on each side of the chosen number the
diff --git a/internal/casino/roulette_racetrack_test.go b/internal/casino/roulette_racetrack_test.go
index 7c465b8..29e2809 100644
--- a/internal/casino/roulette_racetrack_test.go
+++ b/internal/casino/roulette_racetrack_test.go
@@ -33,6 +33,9 @@ func TestRouletteCallBetMath(t *testing.T) {
chips := 0
for _, part := range bet.parts {
chips += part.chips
+ if _, mult := rouletteBet(strings.Join(part.pockets, " "), false); mult != part.mult {
+ t.Fatalf("%s: invalid component %v or multiplier %d (want %d)", bet.name, part.pockets, part.mult, mult)
+ }
}
if chips != bet.chips {
t.Fatalf("%s: parts total %d chips, want %d", bet.name, chips, bet.chips)
@@ -68,7 +71,7 @@ func TestRouletteCallBetAliases(t *testing.T) {
{[]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"},
+ {[]string{"zero", "Zero", "jeu zero", "Jeu Zero", "jeuzero", "JeuZero", "jeu zéro"}, "jeuzero"},
} {
var want rouletteWager
for _, bet := range rouletteCallBets {
@@ -152,13 +155,13 @@ func TestRouletteCallBetPayouts(t *testing.T) {
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)
+ // Jeu Zero: 26 is the straight-up.
+ jeuZero := wager("jeu zero")
+ if got := payout(jeuZero, "26", 4); got != 36 {
+ t.Fatalf("jeu zero 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)
+ if got := payout(jeuZero, "0", 4); got != 18 {
+ t.Fatalf("jeu zero 4 on 0 = %d, want 18", got)
}
// Tiers is a plain six-way split.
tiers := wager("tiers")
@@ -263,3 +266,20 @@ func TestRouletteRacetrackConfig(t *testing.T) {
t.Fatalf("double-zero racetrack accepted: %v", err)
}
}
+
+func TestRouletteZeroNameIsNotPocketZero(t *testing.T) {
+ call, ok := rouletteWagerFor("Zero", false, true)
+ if !ok || len(call.shares) != 7 {
+ t.Fatalf("Zero did not resolve to Jeu Zero: %+v", call)
+ }
+ straight, ok := rouletteWagerFor("0", false, true)
+ if !ok || len(straight.shares) != 1 {
+ t.Fatalf("0 did not resolve to a straight bet: %+v", straight)
+ }
+ if got, err := straight.shares["0"].payout(10); err != nil || got != 360 {
+ t.Fatalf("straight zero return %d, %v", got, err)
+ }
+ if got, err := call.shares["0"].payout(10); err != nil || got != 45 {
+ t.Fatalf("Jeu Zero return %d, %v", got, err)
+ }
+}
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 187bb6d..002d9f0 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -16,9 +16,9 @@ func (c TableConfig) RulesText() string {
rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" +
"1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" +
"Combos: bet 10 on split 1 2 | bet 10 on street 1 2 3 | bet 10 on corner 1 2 4 5 |\n" +
- "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 00 2 | bet 10 on basket.\n"
+ "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 1 2 | bet 10 on basket.\n"
if c.Roulette.Racetrack {
- rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17.\n"
+ rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | jeu zero | neighbors 17.\n"
}
return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
default: