diff options
| author | historia <[not public]> | 2026-09-29 18:58:28 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 18:58:28 -0400 |
| commit | 624626b9a10ae47cf7d5f6e91765570ed759bd74 (patch) | |
| tree | 9812f5d254b7d9cfe1d2fcb6da53362e974541b0 /internal/casino | |
| parent | ff9002b96e35ad0808945e7c35791460c9ca8776 (diff) | |
| download | thehouseoficarus-624626b9a10ae47cf7d5f6e91765570ed759bd74.tar.gz | |
feat: better roulette racetrack graphics and payout table
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/roulette.go | 71 | ||||
| -rw-r--r-- | internal/casino/roulette_geometry_test.go | 109 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack.go | 18 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack_test.go | 34 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 4 |
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: |
