aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/registry.go3
-rw-r--r--internal/casino/roulette.go32
-rw-r--r--internal/casino/roulette_racetrack.go297
-rw-r--r--internal/casino/roulette_racetrack_test.go265
-rw-r--r--internal/casino/roulette_test.go3
-rw-r--r--internal/casino/table_ux.go17
6 files changed, 597 insertions, 20 deletions
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index f27f7bd..7b5de83 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -39,6 +39,9 @@ func init() {
if c.Roulette.SpinTicks < 0 || c.Roulette.SpinTicks > maxSpinTicks {
return fmt.Errorf("roulette spin_ticks must be between 0 and %d", maxSpinTicks)
}
+ if c.Roulette.Racetrack && c.Roulette.DoubleZero {
+ return fmt.Errorf("roulette racetrack is only available on single-zero tables; remove racetrack or set double_zero: false")
+ }
return nil
},
normalize: func(c TableConfig) TableConfig {
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index cae9d0d..252ac4c 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -8,8 +8,11 @@ import (
)
// RouletteConfig selects European (single zero) or American (double zero) rules.
+// Racetrack adds the French call bets and neighbours bets; it is a single-zero
+// layout only.
type RouletteConfig struct {
DoubleZero bool `yaml:"double_zero,omitempty"`
+ Racetrack bool `yaml:"racetrack,omitempty"`
SpinTicks int `yaml:"spin_ticks,omitempty"`
}
@@ -501,7 +504,7 @@ func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable
return &rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{RoomID: room, Config: cfg}), rng: rng}
}
func (t *rouletteTable) view() *RouletteSnapshot {
- s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)}
+ s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)}
for _, p := range t.players.ordered() {
s.Players = append(s.Players, p.Name)
}
@@ -559,15 +562,16 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event {
spot = p.lastSpot
}
spot = normalizeRouletteSpot(spot)
- _, mult := rouletteBet(spot, t.Config.Roulette.DoubleZero)
- if mult == 0 {
+ resolved, ok := rouletteWagerFor(spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack)
+ if !ok {
return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")}
}
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}
}
- // Check total returns before committing even an unlimited wager.
- if _, err := MulPayout(amount, float64(mult)); err != nil {
+ // Check every possible winning return before committing even an unlimited
+ // wager: a call bet's components pay different multiples.
+ if err := resolved.validate(amount); err != nil {
return []Event{t.private(name, "That wager's potential payout is too large.")}
}
wager, err := w.Wager(amount)
@@ -633,15 +637,14 @@ func (t *rouletteTable) settle() []Event {
if !p.HasBet {
continue
}
- covered, mult := rouletteBet(p.lastSpot, t.Config.Roulette.DoubleZero)
+ wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack)
payout := 0
result := "loss"
var err error
- for _, n := range covered {
- if n == pocket {
+ if resolved {
+ if share, ok := wager.covers(pocket); ok {
result = "win"
- payout, err = MulPayout(p.Wager.Total, float64(mult))
- break
+ payout, err = share.payout(p.Wager.Total)
}
}
// PayCredits supports a zero return and atomically consumes a losing
@@ -746,9 +749,12 @@ func (t *rouletteTable) refundAll() []Event {
// colors, Unicode preferences, or card-art settings.
type RouletteSnapshot struct {
DoubleZero bool
- Phase string
- Pockets [3]string // physical wheel neighbors, not numerical neighbors
- Players []string
+ // Racetrack reports that the table also offers the French call bets and
+ // neighbours bets, so the renderer draws the racetrack panel.
+ Racetrack bool
+ Phase string
+ Pockets [3]string // physical wheel neighbors, not numerical neighbors
+ Players []string
// Wheel is the pocket order clockwise from the top; the renderer colors
// and places each pocket. It carries the zero count (one or two) so the
// drawn wheel always matches the configured table.
diff --git a/internal/casino/roulette_racetrack.go b/internal/casino/roulette_racetrack.go
new file mode 100644
index 0000000..624940b
--- /dev/null
+++ b/internal/casino/roulette_racetrack.go
@@ -0,0 +1,297 @@
+package casino
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// rouletteShare is a total-return fraction: a winning pocket pays
+// stake*num/den credits, floor-rounded like a house paying in chips.
+// Ordinary bets have den 1. Call bets split the stake across several chips,
+// so their shares carry a denominator; a pocket covered by more than one
+// component (for example 17 in Orphelins) sums those component returns into
+// one share.
+type rouletteShare struct {
+ num int
+ den int
+}
+
+// payout returns floor(stake*num/den) and refuses a wager whose return would
+// exceed the economy's credit cap, without overflowing stake*num.
+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 {
+ return MaxCredits, fmt.Errorf("payout overflow: %d * %d/%d exceeds %d", stake, s.num, s.den, MaxCredits)
+ }
+ return stake * s.num / s.den, nil
+}
+
+// rouletteWager is a resolved wager: every covered pocket and its total-return
+// share. A wager can cover one pocket (straight), many pockets at one rate
+// (outside or inside combination), or several chip components at different
+// rates (the racetrack call bets).
+type rouletteWager struct {
+ shares map[string]rouletteShare
+}
+
+func (w rouletteWager) covers(pocket string) (rouletteShare, bool) {
+ share, ok := w.shares[pocket]
+ return share, ok
+}
+
+// validate verifies that every possible winning pocket pays a representable
+// amount for this stake before the wager is committed.
+func (w rouletteWager) validate(stake int) error {
+ for _, share := range w.shares {
+ if _, err := share.payout(stake); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// rouletteComponent is one chip group inside a call bet: the pockets it covers
+// and the total-return multiplier (stake included) paid when one of them hits.
+type rouletteComponent struct {
+ pockets []string
+ chips int
+ mult int
+}
+
+// rouletteCallBet is a fixed French/racetrack bet. The series is the display
+// order (wheel order) and parts are the actual chip placement.
+type rouletteCallBet struct {
+ canonical string
+ name string
+ chips int
+ series []string
+ parts []rouletteComponent
+}
+
+// RacetrackBetView is one call bet for the presentation layer.
+type RacetrackBetView struct {
+ Name string
+ Syntax string
+ Chips int
+ Series []string
+}
+
+// RacetrackBets returns the fixed French call bets in display order. The
+// numbers are shown for reference; the actual wagers are the named bets.
+func RacetrackBets() []RacetrackBetView {
+ views := make([]RacetrackBetView, 0, len(rouletteCallBets))
+ for _, bet := range rouletteCallBets {
+ views = append(views, RacetrackBetView{
+ Name: bet.name,
+ Syntax: bet.canonical,
+ Chips: bet.chips,
+ Series: append([]string(nil), bet.series...),
+ })
+ }
+ return views
+}
+
+// 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.
+var rouletteCallBets = []rouletteCallBet{
+ {
+ canonical: "voisins",
+ name: "Voisins du Zero",
+ chips: 9,
+ series: []string{"22", "18", "29", "7", "28", "12", "35", "3", "26", "0", "32", "15", "19", "4", "21", "2", "25"},
+ parts: []rouletteComponent{
+ {pockets: []string{"0", "2", "3"}, chips: 2, mult: 12}, // trio
+ {pockets: []string{"4", "7"}, chips: 1, mult: 18},
+ {pockets: []string{"12", "15"}, chips: 1, mult: 18},
+ {pockets: []string{"18", "21"}, chips: 1, mult: 18},
+ {pockets: []string{"19", "22"}, chips: 1, mult: 18},
+ {pockets: []string{"25", "26", "28", "29"}, chips: 2, mult: 9}, // corner
+ {pockets: []string{"32", "35"}, chips: 1, mult: 18},
+ },
+ },
+ {
+ canonical: "tiers",
+ name: "Tiers du Cylindre",
+ chips: 6,
+ series: []string{"27", "13", "36", "11", "30", "8", "23", "10", "5", "24", "16", "33"},
+ parts: []rouletteComponent{
+ {pockets: []string{"5", "8"}, chips: 1, mult: 18},
+ {pockets: []string{"10", "11"}, chips: 1, mult: 18},
+ {pockets: []string{"13", "16"}, chips: 1, mult: 18},
+ {pockets: []string{"23", "24"}, chips: 1, mult: 18},
+ {pockets: []string{"27", "30"}, chips: 1, mult: 18},
+ {pockets: []string{"33", "36"}, chips: 1, mult: 18},
+ },
+ },
+ {
+ canonical: "orphelins",
+ name: "Orphelins",
+ chips: 5,
+ series: []string{"17", "34", "6", "1", "20", "14", "31", "9"},
+ parts: []rouletteComponent{
+ {pockets: []string{"1"}, chips: 1, mult: 36},
+ {pockets: []string{"6", "9"}, chips: 1, mult: 18},
+ {pockets: []string{"14", "17"}, chips: 1, mult: 18},
+ {pockets: []string{"17", "20"}, chips: 1, mult: 18},
+ {pockets: []string{"31", "34"}, chips: 1, mult: 18},
+ },
+ },
+ {
+ canonical: "zerospiel",
+ name: "Zero Spiel",
+ chips: 4,
+ series: []string{"12", "35", "3", "26", "0", "32", "15"},
+ parts: []rouletteComponent{
+ {pockets: []string{"0", "3"}, chips: 1, mult: 18},
+ {pockets: []string{"12", "15"}, chips: 1, mult: 18},
+ {pockets: []string{"32", "35"}, chips: 1, mult: 18},
+ {pockets: []string{"26"}, chips: 1, mult: 36},
+ },
+ },
+}
+
+// rouletteCallAliases maps the many spellings players use onto a canonical
+// call bet. Accents are stripped before lookup.
+var rouletteCallAliases = map[string]string{
+ "voisins": "voisins",
+ "voisins du zero": "voisins",
+ "grands voisins": "voisins",
+ "grands voisins du zero": "voisins",
+ "vdz": "voisins",
+ "tiers": "tiers",
+ "tiers du cylindre": "tiers",
+ "le tiers du cylindre": "tiers",
+ "tdc": "tiers",
+ "small series": "tiers",
+ "orphelins": "orphelins",
+ "orphans": "orphelins",
+ "les orphelins": "orphelins",
+ "zero spiel": "zerospiel",
+ "zerospiel": "zerospiel",
+ "spiel": "zerospiel",
+ "jeu zero": "zerospiel",
+ "zero game": "zerospiel",
+}
+
+// neighborsRadius is how many pockets on each side of the chosen number the
+// neighbours bet includes: the number plus four either side, nine in total.
+const neighborsRadius = 4
+
+// wager converts a call bet's chip placement into per-pocket return shares,
+// summing shares where components overlap (17 in Orphelins).
+func (b rouletteCallBet) wager() rouletteWager {
+ shares := map[string]rouletteShare{}
+ for _, part := range b.parts {
+ for _, pocket := range part.pockets {
+ share := shares[pocket]
+ shares[pocket] = rouletteShare{num: share.num + part.chips*part.mult, den: b.chips}
+ }
+ }
+ return rouletteWager{shares: shares}
+}
+
+// normalizeRacetrackName lowercases, strips accents and collapses whitespace so
+// "Voisins du Zéro" and "voisins du zero" resolve the same way.
+func normalizeRacetrackName(spot string) string {
+ name := strings.ToLower(strings.TrimSpace(spot))
+ name = strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ô", "o", "û", "u").Replace(name)
+ return strings.Join(strings.Fields(name), " ")
+}
+
+// rouletteRacetrackWager resolves a call bet or a neighbours bet for a
+// single-zero table whose racetrack is enabled.
+func rouletteRacetrackWager(spot string, double bool) (rouletteWager, bool) {
+ if double {
+ return rouletteWager{}, false
+ }
+ if canonical, ok := rouletteCallAliases[normalizeRacetrackName(spot)]; ok {
+ for _, bet := range rouletteCallBets {
+ if bet.canonical == canonical {
+ return bet.wager(), true
+ }
+ }
+ }
+ return rouletteNeighborsWager(spot, double)
+}
+
+// rouletteNeighborsWager covers the chosen pocket and the four pockets on each
+// side of it in wheel order, one chip each (nine chips, a uniform 3:1 return).
+// It accepts "neighbors 17", "neighbors of 17", "17 neighbors" and the British
+// "neighbours" spelling.
+func rouletteNeighborsWager(spot string, double bool) (rouletteWager, bool) {
+ if double {
+ return rouletteWager{}, false
+ }
+ keyword := false
+ number := ""
+ numbers := 0
+ for _, field := range strings.Fields(strings.ToLower(spot)) {
+ switch field {
+ case "neighbor", "neighbors", "neighbour", "neighbours":
+ keyword = true
+ continue
+ case "of", "the", "around":
+ continue
+ }
+ pocket, ok := roulettePocketToken(field, double)
+ if !ok {
+ return rouletteWager{}, false
+ }
+ number = pocket
+ numbers++
+ }
+ if !keyword || numbers != 1 || number == "" {
+ return rouletteWager{}, false
+ }
+ wheel := rouletteWheel(false)
+ index := -1
+ for i, pocket := range wheel {
+ if pocket == number {
+ index = i
+ break
+ }
+ }
+ if index < 0 {
+ return rouletteWager{}, false
+ }
+ shares := map[string]rouletteShare{}
+ for d := -neighborsRadius; d <= neighborsRadius; d++ {
+ pocket := wheel[(index+d+len(wheel))%len(wheel)]
+ shares[pocket] = rouletteShare{num: 36, den: 2*neighborsRadius + 1}
+ }
+ return rouletteWager{shares: shares}, true
+}
+
+// rouletteWagerFor resolves any roulette target into a wager. Call bets and
+// neighbours are reachable only on a single-zero table with the racetrack
+// enabled; every other target uses the ordinary uniform bets.
+func rouletteWagerFor(spot string, double, racetrack bool) (rouletteWager, bool) {
+ if racetrack && !double {
+ if wager, ok := rouletteRacetrackWager(spot, double); ok {
+ return wager, true
+ }
+ }
+ pockets, mult := rouletteBet(spot, double)
+ if mult == 0 {
+ return rouletteWager{}, false
+ }
+ shares := make(map[string]rouletteShare, len(pockets))
+ for _, pocket := range pockets {
+ shares[pocket] = rouletteShare{num: mult, den: 1}
+ }
+ return rouletteWager{shares: shares}, true
+}
+
+// formatShare renders a share for diagnostics and tests without floating
+// point.
+func formatShare(share rouletteShare) string {
+ if share.den == 1 {
+ return strconv.Itoa(share.num)
+ }
+ return fmt.Sprintf("%d/%d", share.num, share.den)
+}
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)
+ }
+}
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index 160ff37..0a43677 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -174,8 +174,7 @@ func TestRouletteCombinationOrderIndependent(t *testing.T) {
}
return true
}
- var permute func([]string, func([]string))
- permute = func(items []string, visit func([]string)) {
+ permute := func(items []string, visit func([]string)) {
var rec func(int)
rec = func(i int) {
if i == len(items) {
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 00009bf..187bb6d 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -13,11 +13,14 @@ func (c TableConfig) RulesText() string {
case GameBaccarat:
return c.BaccaratRulesText()
case GameRoulette:
- return c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" +
+ 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" +
- "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
+ "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 00 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"
+ }
+ return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
default:
return c.Label()
}
@@ -25,10 +28,14 @@ func (c TableConfig) RulesText() string {
func (c TableConfig) Label() string {
if c.Game == GameRoulette {
+ label := "Roulette / Single zero"
if c.Roulette.DoubleZero {
- return "Roulette / Double zero"
+ label = "Roulette / Double zero"
+ }
+ if c.Roulette.Racetrack {
+ label += " / Racetrack"
}
- return "Roulette / Single zero"
+ return label
}
if c.Game != GameBaccarat {
return gameTitle(c.Game)