aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/roulette.md17
-rw-r--r--data/help/roulette.yaml21
-rw-r--r--internal/casino/roulette.go376
-rw-r--r--internal/casino/roulette_test.go202
-rw-r--r--internal/casino/table_ux.go15
-rw-r--r--internal/game/casino.go17
-rw-r--r--internal/game/look_entities.go4
-rw-r--r--internal/game/render_roulette.go25
-rw-r--r--internal/game/render_roulette_test.go33
-rw-r--r--internal/game/roulette_test.go52
10 files changed, 727 insertions, 35 deletions
diff --git a/building_guide/roulette.md b/building_guide/roulette.md
index 0007625..2d3fc53 100644
--- a/building_guide/roulette.md
+++ b/building_guide/roulette.md
@@ -32,7 +32,9 @@ mode and Unicode preference stay per player (the engine snapshot never embeds
ANSI). Three views share one renderer:
- **Betting board**: numbered grid with zeros in the left lane, red/black
- pockets, and outside bets. Green background is reserved for the 0/00 cells.
+ pockets, and outside bets. The whole five-row zero lane (three numbered rows
+ plus the two separators between them) carries the green background, and its
+ outer corners are rounded in Unicode mode.
- **Spinning**: a stylised 17x9 wheel (wider than tall so it reads as a circle
in a terminal cell grid). Only the rim is colored, as solid red and black
pockets alternating around the circumference; pockets are two cells wide
@@ -53,9 +55,16 @@ overview is kept short (`Label | Limits 1-10000 | n/m seated | state`) so it
fits beside a look minimap; `writeLookSideBySide` also wraps any content line
that would otherwise run into the map column.
-Initial scope is **one wager per player per spin**, covering straight-up and
-outside bets (colors, parity, high/low, 1to12/13to24/25to36, rows). Multiple simultaneous
-bets and split/street/corner bets are future work, not silently accepted syntax.
+Initial scope is **one wager per player per spin**. A wager covers straight-up
+numbers, outside bets (colors, parity, high/low, 1to12/13to24/25to36, rows),
+and the combination bets: splits, streets, corners, six-lines, trios, and the
+basket. Combination targets are a compact number list (`split 1 2`,
+`street 1-2-3`, `corner 1 2 4 5`, `line 1 2 3 4 5 6`, `trio 0 00 2`,
+`basket`) joined from the tokens after `on`; the leading name is optional
+because the geometry of the covered set identifies the bet. Non-adjacent or
+wrong-shaped sets are rejected rather than silently accepted. Multiple
+simultaneous wagers remain future work: `Participant.Wager` still holds a
+single stake.
All returns are checked before debiting a wager. Both winning and losing stakes
settle through `PayCredits(return, stakeID)`, including a zero return for losses.
Failures leave the stake pending and do not claim that payment succeeded.
diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml
index c0ab76a..5468995 100644
--- a/data/help/roulette.yaml
+++ b/data/help/roulette.yaml
@@ -21,10 +21,23 @@ description: |
sitout / stop Sit out or leave (queued if a wager is committed)
watch roulette Watch table graphics without joining
- One wager per player per spin. Split, street, corner, and multiple
- simultaneous bets are not supported yet. Zero and double zero lose
- all outside bets. Chips are spent first; winnings return as credits.
- Displayed returns include your stake; odds above are profit odds.
+ Combination bets accept a number list separated by spaces, dashes,
+ slashes or commas. The leading name is optional when the numbers
+ identify the bet on their own.
+
+ bet 10 on split 1 2 Two touching pockets (17:1)
+ bet 10 on 1-4 Horizontal or vertical split
+ bet 10 on street 1 2 3 A three-number column (11:1)
+ bet 10 on corner 1 2 4 5 A 2x2 square (8:1)
+ bet 10 on line 1 2 3 4 5 6 Two adjacent streets (5:1)
+ bet 10 on trio 0 1 2 A zero plus the first street (11:1)
+ bet 10 on basket 0,00,1,2,3 on double zero (6:1);
+ 0,1,2,3 on single zero (8:1)
+
+ One wager per player per spin; a combination is still one wager.
+ Zero and double zero lose all outside bets. Chips are spent first;
+ winnings return as credits. Displayed returns include your stake;
+ odds above are profit odds.
Betting opens with the first wager and runs for the full timer, even
alone. No bets are accepted while the wheel spins. The result shows
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
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 8c10dfd..9282653 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -431,6 +431,10 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
// sets the wager and any other token is the bet target; AFTER "on", the
// next token is always the target. That makes numeric targets reachable
// ("bet 5 on 17"), which roulette, craps, and keno need.
+ // Roulette combination bets need a multi-token target ("split 1 2",
+ // "corner 1 2 4 5"), so a roulette table joins every extra target token.
+ // Every other game keeps the stricter one-token target and its usage error.
+ joinTarget := table.Config.Game == casino.GameRoulette
amount, spot, hasAmount, sawOn := 0, "", false, false
for i, arg := range args {
if strings.EqualFold(arg, "on") {
@@ -472,11 +476,16 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
continue
}
}
- if spot != "" {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
+ if spot == "" {
+ spot = strings.ToLower(arg)
+ continue
}
- spot = strings.ToLower(arg)
+ if joinTarget {
+ spot += " " + strings.ToLower(arg)
+ continue
+ }
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
}
if table.Config.Game == casino.GameBaccarat {
switch spot {
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 2507073..e120c9c 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -231,7 +231,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
var lines []string
for _, table := range room.CasinoTables {
cfg := table.Normalize()
- label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
+ label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
if g.Casino != nil {
if live := g.Casino.Table(room.ID, table.ID); live != nil {
label = g.casinoTimeText(live.Overview())
@@ -244,7 +244,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
if table.Game == casino.GameBaccarat {
alias = "bac"
}
- lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" Join | "+g.colorize(sess, "command", "watch "+alias)+" Spectate")
+ lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" | "+g.colorize(sess, "command", "watch "+alias))
}
for _, machine := range room.CasinoMachines {
label := casinoGameLabel(machine.Game) + " machine"
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index cfded31..47ba5c6 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -53,13 +53,19 @@ func rouletteCenter(text string, width int) string {
return strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2)
}
-type rouletteLines struct{ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string }
+type rouletteLines struct {
+ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string
+ // zeroTL/zeroBL are the zero lane's outer corners. They are rounded while
+ // the rest of the board keeps square joints, so the tall green zero panel
+ // reads as a soft-edged block without disturbing the grid's connections.
+ zeroTL, zeroBL string
+}
func rouletteBorders(unicode bool) rouletteLines {
if unicode {
- return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼"}
+ return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼", "╭", "╰"}
}
- return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"}
+ return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+", "+", "+"}
}
func repeatRoulette(s string, n int) []string {
@@ -97,7 +103,7 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string {
// everywhere else.
gridBottom := func() string {
var sb strings.Builder
- sb.WriteString(b.bl)
+ sb.WriteString(b.zeroBL)
for col := 6; col <= 66; col += 5 {
if col == 6 {
sb.WriteString(strings.Repeat(b.h, 5))
@@ -115,7 +121,7 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string {
sb.WriteString(b.br)
return sb.String()
}
- lines = append(lines, rouletteInk(mode, gridRule(b.tl, b.teeTop, b.tr)))
+ lines = append(lines, rouletteInk(mode, gridRule(b.zeroTL, b.teeTop, b.tr)))
for row := 3; row >= 1; row-- {
zero := ""
switch {
@@ -144,7 +150,9 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string {
line += rouletteInk(mode, rouletteCenter(fmt.Sprintf("row%d", 4-row), rowCell)+b.v)
lines = append(lines, line)
if row > 1 {
- line = rouletteInk(mode, b.v+" "+b.teeLeft+
+ // The zero lane is one green rectangle, so the two separator rows
+ // are painted to match the cells above and below them.
+ line = rouletteInk(mode, b.v) + roulettePaint(mode, " ", 2) + rouletteInk(mode, b.teeLeft+
strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+
b.cross+strings.Repeat(b.h, rowCell)+b.teeRight)
lines = append(lines, line)
@@ -428,9 +436,10 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int
// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and
// "table rules"), never with the ambient betting board.
func (g *Game) rouletteReminder(sess *net.Session) string {
- text := "Numbers 35:1 | 1to12 / 13to24 / 25to36 and rows 2:1 | Other outside bets 1:1\n" +
+ text := "Numbers 35:1 | Split 17:1 | Street/trio 11:1 | Corner 8:1 | Line 5:1 | Dozens & rows 2:1 | Outside 1:1\n" +
+ "Combos: split 1 2 | street 1 2 3 | corner 1 2 4 5 | line 1 2 3 4 5 6 | trio 0 1 2 | basket (double zero)\n" +
"Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" +
- "bet 10 on 17 / bet 10 on red / bet 10 on 13to24"
+ "bet 10 on 17 / bet 10 on red / bet 10 on split 1 2"
return g.colorize(sess, "casino_action", text)
}
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index b1a6005..1cb75ce 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -86,9 +86,9 @@ func TestRouletteArtModes(t *testing.T) {
}
}
if mode != "none" {
- // The whole 0/00 lane is green: blank cells above and below
- // the zeros fill the rectangle.
- wantGreen := 3
+ // The whole 0/00 lane is green: the three numbered rows plus
+ // the two separator rows that run through the lane.
+ wantGreen := 5
if got := strings.Count(layoutArt, color.BgCode(mode, 2)); got != wantGreen {
t.Fatalf("green cells on board = %d, want %d", got, wantGreen)
}
@@ -199,6 +199,33 @@ func TestRouletteLayoutConnections(t *testing.T) {
}
}
+// TestRouletteZeroPanel checks the two zero-panel fixes: the whole five-row
+// lane carries the green background, and its outer corners are rounded in
+// Unicode mode while ASCII mode stays square.
+func TestRouletteZeroPanel(t *testing.T) {
+ for _, double := range []bool{false, true} {
+ colored := renderRouletteLayout(double, "xterm256", true)
+ rows := strings.Split(colored, "\n")
+ // Interior rows 2-6: top numbered row, separator, middle, separator,
+ // bottom numbered row. Each must contain the green background.
+ for row := 2; row <= 6; row++ {
+ if !strings.Contains(rows[row], color.BgCode("xterm256", 2)) {
+ t.Fatalf("double=%v: zero lane row %d is not green:\n%s", double, row, plainRoulette(colored))
+ }
+ }
+ plainRows := strings.Split(plainRoulette(colored), "\n")
+ if !strings.HasPrefix(plainRows[1], "╭") || !strings.HasPrefix(plainRows[7], "╰") {
+ t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored))
+ }
+ ascii := plainRoulette(renderRouletteLayout(double, "none", false))
+ for _, r := range ascii {
+ if r > 127 {
+ t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r)
+ }
+ }
+ }
+}
+
func TestRouletteResultSizesAndNeighbors(t *testing.T) {
for _, unicode := range []bool{false, true} {
for _, size := range []int{1, 5, 7, 9} {
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
index a963913..7a22d9b 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -43,6 +43,58 @@ func TestRouletteNumericTargetsAndRendering(t *testing.T) {
}
}
+func TestRouletteCombinationTargetParsing(t *testing.T) {
+ for _, double := range []bool{false, true} {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double}}
+ g.Casino.Join(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ // Multi-token targets are joined for roulette, with or without "on".
+ valid := [][]string{
+ {"10", "on", "split", "1", "2"},
+ {"10", "on", "split", "1-2"},
+ {"10", "split", "1-2"},
+ {"10", "on", "street", "1", "2", "3"},
+ {"10", "on", "street", "3", "1", "2"},
+ {"10", "on", "corner", "1", "2", "4", "5"},
+ {"10", "on", "corner", "5", "1", "4", "2"},
+ {"10", "on", "line", "1", "2", "3", "4", "5", "6"},
+ {"10", "on", "trio", "0", "1", "2"},
+ {"10", "on", "basket"},
+ }
+ if double {
+ valid = append(valid, []string{"10", "on", "split", "0", "00"})
+ }
+ for _, args := range valid {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("double=%v args=%v: expected wallet failure, got %q", double, args, conn.data)
+ }
+ }
+ // Non-adjacent or wrong-shaped combinations must not reach the wallet.
+ invalid := [][]string{
+ {"10", "on", "split", "1", "5"},
+ {"10", "on", "street", "2", "3", "4"},
+ {"10", "on", "corner", "1", "2", "3", "4"},
+ {"10", "on", "line", "1", "2", "3", "4", "5", "7"},
+ {"10", "on", "trio", "0", "1", "5"},
+ }
+ if !double {
+ invalid = append(invalid, []string{"10", "on", "split", "0", "00"})
+ }
+ for _, args := range invalid {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "Unknown roulette target") {
+ t.Fatalf("double=%v args=%v: expected target rejection, got %q", double, args, conn.data)
+ }
+ }
+ }
+}
+
func TestCasinoTestPortalAuthoredContract(t *testing.T) {
g, rooms, _ := loadVesta(t)
found := false