aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 16:50:40 -0400
committerhistoria <[not public]>2026-09-29 16:50:40 -0400
commit00fe5f7ead43627fa283035b27365924b76c5a0f (patch)
tree07546518a51aaa037d64813f40eb6dc4596bd9cb /internal/casino
parentbfe22e10b5b9f2c7039d1d67021229659a06a226 (diff)
downloadthehouseoficarus-00fe5f7ead43627fa283035b27365924b76c5a0f.tar.gz
feat: roulette bets street, corner, split, etc.
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/roulette.go376
-rw-r--r--internal/casino/roulette_test.go202
-rw-r--r--internal/casino/table_ux.go15
3 files changed, 583 insertions, 10 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 5784ac9..cae9d0d 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -56,9 +56,24 @@ func normalizeRouletteSpot(spot string) string {
}
// Returns the covered pockets and total-return multiplier (stake included).
-// Zero never belongs to an outside bet.
+// Zero never belongs to an outside bet. Combination bets (splits, streets,
+// corners, six-lines, trios and the basket) are parsed from a compact number
+// list: the leading keyword is optional because the covered set uniquely
+// identifies the bet.
func rouletteBet(spot string, double bool) ([]string, int) {
spot = normalizeRouletteSpot(spot)
+ if pockets, mult := rouletteStraightBet(spot, double); mult > 0 {
+ return pockets, mult
+ }
+ if pockets, mult := rouletteOutsideBet(spot); mult > 0 {
+ return pockets, mult
+ }
+ return rouletteCombinationBet(spot, double)
+}
+
+// rouletteStraightBet covers a single pocket: any number, 0, or 00 (American
+// tables only). It pays 35:1, a 36x total return.
+func rouletteStraightBet(spot string, double bool) ([]string, int) {
if spot == "00" {
if double {
return []string{spot}, 36
@@ -68,6 +83,12 @@ func rouletteBet(spot string, double bool) ([]string, int) {
if n, err := strconv.Atoi(spot); err == nil && n >= 0 && n <= 36 && strconv.Itoa(n) == spot {
return []string{spot}, 36
}
+ return nil, 0
+}
+
+// rouletteOutsideBet covers the even-money, dozen, and row wagers, whose
+// pocket counts all divide 36 exactly, so the return is 36/len.
+func rouletteOutsideBet(spot string) ([]string, int) {
var pockets []string
for n := 1; n <= 36; n++ {
hit := false
@@ -107,6 +128,359 @@ func rouletteBet(spot string, double bool) ([]string, int) {
return pockets, 36 / len(pockets)
}
+// rouletteComboKeywords maps the accepted names for a combination bet onto
+// its canonical kind. Several names share a kind ("square" is a corner,
+// "sixline"/"double street" is a line).
+var rouletteComboKeywords = map[string]string{
+ "split": "split",
+ "street": "street",
+ "corner": "corner",
+ "square": "corner",
+ "line": "line",
+ "sixline": "line",
+ "six-line": "line",
+ "doublestreet": "line",
+ "double-street": "line",
+ "trio": "trio",
+ "basket": "basket",
+}
+
+// Payout returns are 36/covered for every combination except the five-number
+// American basket, which pays a deliberately short 6:1 (7x total).
+func rouletteCombinationBet(spot string, double bool) ([]string, int) {
+ kind, nums, ok := parseRouletteCombination(spot, double)
+ if !ok {
+ return nil, 0
+ }
+ if kind == "" {
+ kind = inferRouletteCombination(nums)
+ }
+ switch kind {
+ case "split":
+ if len(nums) == 2 && rouletteAdjacent(nums[0], nums[1], double) {
+ return nums, 18
+ }
+ case "street":
+ if rouletteIsStreet(nums) {
+ return nums, 12
+ }
+ case "corner":
+ if rouletteIsCorner(nums, double) {
+ return nums, 9
+ }
+ case "line":
+ if rouletteIsLine(nums) {
+ return nums, 6
+ }
+ case "trio":
+ if rouletteIsTrio(nums, double) {
+ return nums, 12
+ }
+ case "basket":
+ if pockets, mult, valid := rouletteBasket(nums, double); valid {
+ return pockets, mult
+ }
+ }
+ return nil, 0
+}
+
+// parseRouletteCombination strips an optional leading keyword and converts the
+// remaining number tokens (separated by spaces, dashes, slashes, commas or
+// backslashes) into canonical pocket names. Duplicate numbers are rejected.
+func parseRouletteCombination(spot string, double bool) (string, []string, bool) {
+ fields := strings.Fields(strings.ToLower(spot))
+ if len(fields) == 0 {
+ return "", nil, false
+ }
+ kind := ""
+ switch fields[0] {
+ case "split", "street", "corner", "square", "line", "sixline", "six-line", "doublestreet", "double-street", "trio", "basket":
+ kind = fields[0]
+ fields = fields[1:]
+ case "six":
+ if len(fields) > 1 && fields[1] == "line" {
+ kind, fields = "sixline", fields[2:]
+ }
+ case "double":
+ if len(fields) > 1 && fields[1] == "street" {
+ kind, fields = "doublestreet", fields[2:]
+ }
+ }
+ if canonical, ok := rouletteComboKeywords[kind]; ok {
+ kind = canonical
+ } else if kind != "" {
+ return "", nil, false
+ }
+ if kind == "basket" && len(fields) == 0 {
+ return "basket", nil, true
+ }
+ if len(fields) == 0 {
+ return "", nil, false
+ }
+ raw := strings.NewReplacer("-", " ", "/", " ", ",", " ", "\\", " ").Replace(strings.Join(fields, " "))
+ seen := map[string]bool{}
+ var nums []string
+ for _, tok := range strings.Fields(raw) {
+ pocket, ok := roulettePocketToken(tok, double)
+ if !ok || seen[pocket] {
+ return "", nil, false
+ }
+ seen[pocket] = true
+ nums = append(nums, pocket)
+ }
+ if len(nums) == 0 {
+ return "", nil, false
+ }
+ return kind, nums, true
+}
+
+// roulettePocketToken canonicalizes one number token: a plain 0-36 value or
+// 00, rejecting non-canonical spellings such as "01", "+1" or "000".
+func roulettePocketToken(tok string, double bool) (string, bool) {
+ if tok == "00" {
+ return "00", double
+ }
+ n, err := strconv.Atoi(tok)
+ if err != nil || n < 0 || n > 36 || strconv.Itoa(n) != tok {
+ return "", false
+ }
+ return strconv.Itoa(n), true
+}
+
+// inferRouletteCombination identifies a bare number list by its size. Adjacency
+// and geometry are validated after this, so an ambiguous count still fails.
+func inferRouletteCombination(nums []string) string {
+ switch len(nums) {
+ case 2:
+ return "split"
+ case 3:
+ if rouletteIsStreet(nums) {
+ return "street"
+ }
+ return "trio"
+ case 4:
+ return "corner"
+ case 5:
+ return "basket"
+ case 6:
+ return "line"
+ }
+ return ""
+}
+
+// rouletteNumberCell maps 1-36 onto its column and row on the felt: column
+// 0-11 left to right, row 0-2 bottom to top (row 0 holds 1,4,7,...).
+func rouletteNumberCell(n string) (col, row int, ok bool) {
+ v, err := strconv.Atoi(n)
+ if err != nil || v < 1 || v > 36 {
+ return 0, 0, false
+ }
+ return (v - 1) / 3, (v - 1) % 3, true
+}
+
+// rouletteAdjacent reports whether two pockets share an edge: horizontally
+// (same row, neighboring columns) or vertically (same column, neighboring
+// rows). Zero and double zero use the felt's special zero edge.
+func rouletteAdjacent(a, b string, double bool) bool {
+ if a == "0" || a == "00" || b == "0" || b == "00" {
+ return rouletteZeroAdjacent(a, b, double)
+ }
+ ac, ar, ok := rouletteNumberCell(a)
+ if !ok {
+ return false
+ }
+ bc, br, ok := rouletteNumberCell(b)
+ if !ok {
+ return false
+ }
+ dc, dr := ac-bc, ar-br
+ if dc < 0 {
+ dc = -dc
+ }
+ if dr < 0 {
+ dr = -dr
+ }
+ 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.
+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 a == "0" || a == "00" {
+ return neighbors(b)
+ }
+ return neighbors(a)
+}
+
+func rouletteIsStreet(nums []string) bool {
+ if len(nums) != 3 {
+ return false
+ }
+ set := map[int]bool{}
+ for _, s := range nums {
+ v, err := strconv.Atoi(s)
+ if err != nil {
+ return false
+ }
+ set[v] = true
+ }
+ if len(set) != 3 {
+ return false
+ }
+ for n := 1; n <= 34; n += 3 {
+ if set[n] && set[n+1] && set[n+2] {
+ return true
+ }
+ }
+ return false
+}
+
+func rouletteIsCorner(nums []string, double bool) bool {
+ if len(nums) != 4 {
+ return false
+ }
+ // The European "first four" (0,1,2,3) is the only zero corner.
+ if !double && rouletteIsFirstFour(nums) {
+ return true
+ }
+ cells := map[[2]int]bool{}
+ for _, s := range nums {
+ c, r, ok := rouletteNumberCell(s)
+ if !ok {
+ return false
+ }
+ cells[[2]int{c, r}] = true
+ }
+ 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
+}
+
+func rouletteIsFirstFour(nums []string) bool {
+ if len(nums) != 4 {
+ return false
+ }
+ set := map[string]bool{}
+ for _, s := range nums {
+ set[s] = true
+ }
+ return set["0"] && set["1"] && set["2"] && set["3"]
+}
+
+// A six-line is two neighboring columns, i.e. six numbers across all three
+// rows of two adjacent streets.
+func rouletteIsLine(nums []string) bool {
+ if len(nums) != 6 {
+ return false
+ }
+ cols := map[int]bool{}
+ set := map[int]bool{}
+ for _, s := range nums {
+ c, _, ok := rouletteNumberCell(s)
+ if !ok {
+ return false
+ }
+ v, _ := strconv.Atoi(s)
+ cols[c] = true
+ set[v] = true
+ }
+ if len(cols) != 2 || len(set) != 6 {
+ return false
+ }
+ var c1, c2 int
+ first := true
+ for c := range cols {
+ if first {
+ c1, first = c, false
+ } else {
+ c2 = c
+ }
+ }
+ if c1 > c2 {
+ c1, c2 = c2, c1
+ }
+ 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.
+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
+ }
+ }
+ }
+ return hasZero
+}
+
+// The basket is the American five-number 0/00/1/2/3 bet, which pays a short
+// 6:1. On a single-zero felt the name covers the European first four instead.
+// An empty list means the bare "basket" keyword.
+func rouletteBasket(nums []string, double bool) ([]string, int, bool) {
+ if len(nums) == 0 {
+ if double {
+ return []string{"0", "00", "1", "2", "3"}, 7, true
+ }
+ return []string{"0", "1", "2", "3"}, 9, true
+ }
+ if double {
+ set := map[string]bool{}
+ for _, s := range nums {
+ set[s] = true
+ }
+ if len(nums) != 5 {
+ return nil, 0, false
+ }
+ for _, need := range []string{"0", "00", "1", "2", "3"} {
+ if !set[need] {
+ return nil, 0, false
+ }
+ }
+ return []string{"0", "00", "1", "2", "3"}, 7, true
+ }
+ if rouletteIsFirstFour(nums) {
+ return []string{"0", "1", "2", "3"}, 9, true
+ }
+ return nil, 0, false
+}
+
type roulettePlayer struct {
Participant
lastSpot string
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index a20b17c..160ff37 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -1,6 +1,7 @@
package casino
import (
+ "fmt"
"math/rand"
"strconv"
"strings"
@@ -66,6 +67,207 @@ func TestRouletteRowBetMapping(t *testing.T) {
}
}
+// TestRouletteCombinationBets covers splits, streets, corners, six-lines,
+// trios and the basket, including the geometry that must be rejected and the
+// bare-number-list inference that lets the leading keyword be omitted.
+func TestRouletteCombinationBets(t *testing.T) {
+ same := func(got []string, want ...string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ set := map[string]bool{}
+ for _, g := range got {
+ set[g] = true
+ }
+ for _, w := range want {
+ if !set[w] {
+ return false
+ }
+ }
+ return true
+ }
+ cases := []struct {
+ spot string
+ double bool
+ mult int
+ want []string
+ }{
+ // Splits cover edge-neighbours only, horizontally or vertically.
+ {"split 1 2", false, 18, []string{"1", "2"}},
+ {"1-2", false, 18, []string{"1", "2"}},
+ {"1/4", false, 18, []string{"1", "4"}},
+ {"split 1 5", false, 0, nil},
+ {"split 3 4", false, 0, nil},
+ {"split 0 3", false, 18, []string{"0", "3"}},
+ // Streets are one three-row column.
+ {"street 1 2 3", false, 12, []string{"1", "2", "3"}},
+ {"1-2-3", false, 12, []string{"1", "2", "3"}},
+ {"street 2 3 4", false, 0, nil},
+ // Corners are a two-by-two block.
+ {"corner 1 2 4 5", false, 9, []string{"1", "2", "4", "5"}},
+ {"1-2-4-5", false, 9, []string{"1", "2", "4", "5"}},
+ {"corner 1 2 3 4", false, 0, nil},
+ // Six-lines are two adjacent streets.
+ {"line 1 2 3 4 5 6", false, 6, []string{"1", "2", "3", "4", "5", "6"}},
+ {"1-2-3-4-5-6", false, 6, []string{"1", "2", "3", "4", "5", "6"}},
+ {"line 1 2 3 4 5 7", false, 0, nil},
+ // Trios pair a zero with the first street.
+ {"trio 0 1 2", false, 12, []string{"0", "1", "2"}},
+ {"0-1-2", false, 12, []string{"0", "1", "2"}},
+ {"trio 0 00 2", true, 12, []string{"0", "00", "2"}},
+ {"trio 0 00 2", false, 0, nil},
+ // The American basket is five numbers at a deliberately short 6:1.
+ {"basket", true, 7, []string{"0", "00", "1", "2", "3"}},
+ {"basket 0 00 1 2 3", true, 7, []string{"0", "00", "1", "2", "3"}},
+ {"basket 0 00 1 2", true, 0, nil},
+ // Single zero falls back to the European first four.
+ {"basket", false, 9, []string{"0", "1", "2", "3"}},
+ {"corner 0 1 2 3", false, 9, []string{"0", "1", "2", "3"}},
+ // Zero-to-zero is an American split only.
+ {"split 0 00", true, 18, []string{"0", "00"}},
+ {"split 0 00", false, 0, nil},
+ }
+ for _, tc := range cases {
+ got, mult := rouletteBet(tc.spot, tc.double)
+ if mult != tc.mult {
+ t.Fatalf("%q double=%v: mult=%d, want %d", tc.spot, tc.double, mult, tc.mult)
+ }
+ if tc.mult == 0 {
+ continue
+ }
+ if !same(got, tc.want...) {
+ t.Fatalf("%q double=%v: pockets=%v, want %v", tc.spot, tc.double, got, tc.want)
+ }
+ // Every fair combination covers a divisor of 36; the American basket
+ // is the one intentional exception (5 x 7 = 35, not 36).
+ if tc.mult != 7 && len(got)*mult != 36 {
+ t.Fatalf("%q: coverage/payout %d/%d", tc.spot, len(got), mult)
+ }
+ }
+ // A bare list and its named form must reach the same bet.
+ for _, pair := range [][2]string{{"1-2", "split 1 2"}, {"1-2-3", "street 1 2 3"}, {"1-2-4-5", "corner 1 2 4 5"}, {"1-2-3-4-5-6", "line 1 2 3 4 5 6"}} {
+ bare, bareMult := rouletteBet(pair[0], false)
+ named, namedMult := rouletteBet(pair[1], false)
+ if bareMult != namedMult || !same(bare, named...) {
+ t.Fatalf("inference %q != %q: %v/%d vs %v/%d", pair[0], pair[1], bare, bareMult, named, namedMult)
+ }
+ }
+}
+
+// TestRouletteCombinationOrderIndependent checks that every permutation of a
+// combination's numbers selects the same bet. Street 3-1-2 must be the 1-2-3
+// street, and so on for splits, corners, lines, trios and the basket. The
+// payout multiplier and the covered pockets must not depend on input order.
+func TestRouletteCombinationOrderIndependent(t *testing.T) {
+ same := func(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ set := map[string]bool{}
+ for _, x := range a {
+ set[x] = true
+ }
+ for _, x := range b {
+ if !set[x] {
+ return false
+ }
+ }
+ return true
+ }
+ var permute func([]string, func([]string))
+ permute = func(items []string, visit func([]string)) {
+ var rec func(int)
+ rec = func(i int) {
+ if i == len(items) {
+ visit(append([]string(nil), items...))
+ return
+ }
+ for j := i; j < len(items); j++ {
+ items[i], items[j] = items[j], items[i]
+ rec(i + 1)
+ items[i], items[j] = items[j], items[i]
+ }
+ }
+ rec(0)
+ }
+ cases := []struct {
+ kind string
+ nums []string
+ double bool
+ }{
+ {"split", []string{"1", "2"}, false},
+ {"split", []string{"0", "00"}, true},
+ {"street", []string{"1", "2", "3"}, false},
+ {"corner", []string{"1", "2", "4", "5"}, false},
+ {"line", []string{"1", "2", "3", "4", "5", "6"}, false},
+ {"trio", []string{"0", "1", "2"}, false},
+ {"trio", []string{"0", "00", "2"}, true},
+ {"basket", []string{"0", "00", "1", "2", "3"}, true},
+ }
+ for _, tc := range cases {
+ canonical := tc.kind + " " + strings.Join(tc.nums, " ")
+ wantPockets, wantMult := rouletteBet(canonical, tc.double)
+ if wantMult == 0 {
+ t.Fatalf("canonical %q is invalid", canonical)
+ }
+ permute(append([]string(nil), tc.nums...), func(p []string) {
+ spot := tc.kind + " " + strings.Join(p, " ")
+ got, mult := rouletteBet(spot, tc.double)
+ if mult != wantMult || !same(got, wantPockets) {
+ t.Fatalf("%q: got %v/%d, want %v/%d", spot, got, mult, wantPockets, wantMult)
+ }
+ })
+ }
+}
+
+// TestRouletteCombinationSettlement drives a split through the real table
+// phases, so coverage and payout are exercised through settle, not only by
+// calling rouletteBet directly.
+func TestRouletteCombinationSettlement(t *testing.T) {
+ table := rouletteFixture()
+ wheel := rouletteWheel(false)
+ index := rand.New(rand.NewSource(7)).Intn(len(wheel))
+ pocket := wheel[index]
+ // Pick a real split covering the sampled pocket.
+ split := ""
+ for n := 1; n <= 36 && split == ""; n++ {
+ for _, m := range []int{n + 1, n + 3} {
+ if m > 36 {
+ continue
+ }
+ spot := fmt.Sprintf("split %d %d", n, m)
+ covered, mult := rouletteBet(spot, false)
+ if mult == 0 {
+ continue
+ }
+ for _, p := range covered {
+ if p == pocket {
+ split = spot
+ break
+ }
+ }
+ if split != "" {
+ break
+ }
+ }
+ }
+ if split == "" {
+ split = "split 0 1"
+ if _, mult := rouletteBet(split, false); mult == 0 {
+ t.Fatal("no split covers pocket ", pocket)
+ }
+ }
+ table.join("a")
+ w := &testWallet{chips: 100}
+ table.bet("a", split, 10, w)
+ for i := 0; i < 4; i++ {
+ table.tick()
+ }
+ if w.paid != 180 || len(w.open) != 0 {
+ t.Fatalf("split %q pocket %s: wallet %+v", split, pocket, w)
+ }
+}
+
func rouletteFixture() *rouletteTable {
return newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{SpinTicks: 2}}, rand.New(rand.NewSource(7)))
}
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index db700d6..00009bf 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -13,7 +13,11 @@ func (c TableConfig) RulesText() string {
case GameBaccarat:
return c.BaccaratRulesText()
case GameRoulette:
- return c.Label() + "\nStraight numbers pay 35:1; 1to12/13to24/25to36 and rows 2:1; other outside bets 1:1.\nZero pockets lose all outside bets. One wager per spin. Use help roulette."
+ return 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" +
+ "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
default:
return c.Label()
}
@@ -48,14 +52,7 @@ func (c *tableCore[P]) overview() string {
func (c *tableCore[P]) overviewLocked() string {
// Concise enough to sit beside a look minimap without wrapping into it.
// The betting countdown lives in the menu heading and warning lines.
- state := "Betting open"
- switch c.phase {
- case phasePlaying:
- state = "In progress"
- case phaseInsurance:
- state = "Insurance"
- }
- return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state)
+ return fmt.Sprintf("%s | Limits %s | %d/%d seated", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers)
}
// compactBetRange renders the wager range in the short form used by the