aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/render_roulette.go59
-rw-r--r--internal/game/render_roulette_test.go66
-rw-r--r--internal/game/roulette_test.go44
10 files changed, 740 insertions, 48 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)
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index e218653..de62797 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -112,7 +112,7 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
}
g.emitCasinoEvents(t.Status(sess.Player.Name))
if t.Config.Game == casino.GameRoulette {
- sess.WriteLine(g.rouletteReminder(sess))
+ sess.WriteLine(g.rouletteReminder(sess, t.Config))
}
if len(args) > 0 {
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index 47ba5c6..159fadc 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -21,7 +21,7 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo
case "result":
return renderRouletteResult(s.Pockets, mode, unicode, size)
default:
- return renderRouletteLayout(s.DoubleZero, mode, unicode)
+ return renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode)
}
}
@@ -76,18 +76,49 @@ func repeatRoulette(s string, n int) []string {
return parts
}
+// renderRouletteRacetrack draws the French call-bet track above the board. The
+// numbers are shown in wheel order for reference; the named bets are what the
+// player places. The Neighbors line explains the one bet that takes a number.
+func renderRouletteRacetrack(mode string, unicode bool) string {
+ b := rouletteBorders(unicode)
+ tl, tr, bl, br := b.tl, b.tr, b.bl, b.br
+ if unicode {
+ tl, tr, bl, br = "╭", "╮", "╰", "╯"
+ }
+ // The panel matches the betting board's 74-column footprint.
+ const width = 72
+ bets := casino.RacetrackBets()
+ rows := make([]string, 0, len(bets)+1)
+ for _, bet := range bets {
+ label := fmt.Sprintf("%s (%d)", bet.Name, bet.Chips)
+ rows = append(rows, fmt.Sprintf("%-22s %s", label, strings.Join(bet.Series, " ")))
+ }
+ rows = append(rows, fmt.Sprintf("%-22s %s", "Neighbors (9)", "neighbors <n>: n plus four each side"))
+ lines := []string{rouletteInk(mode, tl+strings.Repeat(b.h, width)+tr)}
+ for _, row := range rows {
+ row = " " + row
+ if pad := width - color.VisibleLen(row); pad > 0 {
+ row += strings.Repeat(" ", pad)
+ }
+ lines = append(lines, rouletteInk(mode, b.v+row+b.v))
+ }
+ lines = append(lines, rouletteInk(mode, bl+strings.Repeat(b.h, width)+br))
+ return strings.Join(lines, "\n")
+}
+
// renderRouletteLayout draws the betting board. Green is reserved for the
// 0/00 lane: every other cell is red, black, or plain. The odds reminder is
// not part of the board (it is shown by the "table" command), so the board
// stays compact enough to sit in a betting menu.
-func renderRouletteLayout(double bool, mode string, unicode bool) string {
+func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) string {
+ // The call bets are a single-zero layout; never draw the track on an
+ // American board even if a malformed snapshot asks for it.
+ racetrack = racetrack && !double
b := rouletteBorders(unicode)
var lines []string
- title := "ROULETTE / SINGLE ZERO"
- if double {
- title = "ROULETTE / DOUBLE ZERO"
+ if racetrack {
+ lines = append(lines, renderRouletteRacetrack(mode, unicode))
}
- lines = append(lines, rouletteInk(mode, title))
// Five columns for the zero lane, twelve four-column pockets, and a
// six-column 2:1 lane on the right holding the row bets. Four adjacent
// pockets line up exactly over each 19-column dozen.
@@ -434,11 +465,15 @@ 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 {
+// "table rules"), never with the ambient betting board. Racetrack tables also
+// list their call-bet syntax.
+func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string {
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" +
+ "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"
+ if cfg.Roulette.Racetrack {
+ text += "Racetrack: bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17\n"
+ }
+ text += "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" +
"bet 10 on 17 / bet 10 on red / bet 10 on split 1 2"
return g.colorize(sess, "casino_action", text)
}
@@ -446,8 +481,8 @@ func (g *Game) rouletteReminder(sess *net.Session) string {
func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string {
rules := g.colorize(sess, "casino_action", cfg.RulesText())
if cfg.Game == casino.GameRoulette {
- snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
- rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess)
+ snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)}
+ rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg)
}
return rules
}
diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go
index 1cb75ce..d992abf 100644
--- a/internal/game/render_roulette_test.go
+++ b/internal/game/render_roulette_test.go
@@ -64,14 +64,14 @@ func TestRouletteArtModes(t *testing.T) {
}
}
- layoutArt := renderRouletteLayout(double, mode, unicode)
+ layoutArt := renderRouletteLayout(double, false, mode, unicode)
layout := plainRoulette(layoutArt)
rows := strings.Split(layout, "\n")
if double {
- if !strings.Contains(string([]rune(rows[2])[:7]), "0") || !strings.Contains(string([]rune(rows[6])[:7]), "00") {
+ if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") {
t.Fatal("double zero not left of grid", layout)
}
- } else if !strings.Contains(string([]rune(rows[4])[:7]), "0") {
+ } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") {
t.Fatal("zero not left of grid", layout)
}
for _, target := range []string{"1to12", "13to24", "25to36", "row1", "row2", "row3", "red", "black", "odd", "even", "low", "high"} {
@@ -157,12 +157,12 @@ func TestRouletteWheelGreenPockets(t *testing.T) {
// each outside-bet divider.
func TestRouletteLayoutConnections(t *testing.T) {
for _, double := range []bool{false, true} {
- art := plainRoulette(renderRouletteLayout(double, "xterm256", true))
+ art := plainRoulette(renderRouletteLayout(double, false, "xterm256", true))
rows := strings.Split(art, "\n")
- // rows[7] is the numbered grid's bottom edge, directly above the dozen
+ // rows[6] is the numbered grid's bottom edge, directly above the dozen
// labels. The dozen boundaries and the grid/row-lane joint cross; the
// row-bet lane closes with a bottom-right corner.
- gridBottom := []rune(rows[7])
+ gridBottom := []rune(rows[6])
for _, col := range []int{6, 26, 46, 66} {
if gridBottom[col] != '┼' {
t.Fatalf("double=%v: grid bottom col %d = %q, want ┼:\n%s", double, col, gridBottom[col], art)
@@ -173,15 +173,15 @@ func TestRouletteLayoutConnections(t *testing.T) {
}
// The row-bet lane is boxed: its separators cross at the grid edge and
// close on the right.
- for _, idx := range []int{3, 5} {
+ for _, idx := range []int{2, 4} {
sep := []rune(rows[idx])
if sep[66] != '┼' || sep[73] != '┤' {
t.Fatalf("double=%v: row lane separator %q lacks box joints:\n%s", double, rows[idx], art)
}
}
- // rows[9] is the dozen boxes' bottom edge: tees point down where the
+ // rows[8] is the dozen boxes' bottom edge: tees point down where the
// even-money dividers sit and crosses pass the dozen dividers through.
- dozenBottom := []rune(rows[9])
+ dozenBottom := []rune(rows[8])
wantDozen := map[int]rune{6: '├', 16: '┬', 26: '┼', 36: '┬', 46: '┼', 56: '┬', 66: '┤'}
for col, want := range wantDozen {
if dozenBottom[col] != want {
@@ -204,20 +204,20 @@ func TestRouletteLayoutConnections(t *testing.T) {
// Unicode mode while ASCII mode stays square.
func TestRouletteZeroPanel(t *testing.T) {
for _, double := range []bool{false, true} {
- colored := renderRouletteLayout(double, "xterm256", true)
+ colored := renderRouletteLayout(double, false, "xterm256", true)
rows := strings.Split(colored, "\n")
- // Interior rows 2-6: top numbered row, separator, middle, separator,
+ // Interior rows 1-5: top numbered row, separator, middle, separator,
// bottom numbered row. Each must contain the green background.
- for row := 2; row <= 6; row++ {
+ for row := 1; row <= 5; 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], "╰") {
+ if !strings.HasPrefix(plainRows[0], "╭") || !strings.HasPrefix(plainRows[6], "╰") {
t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored))
}
- ascii := plainRoulette(renderRouletteLayout(double, "none", false))
+ ascii := plainRoulette(renderRouletteLayout(double, false, "none", false))
for _, r := range ascii {
if r > 127 {
t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r)
@@ -226,6 +226,44 @@ func TestRouletteZeroPanel(t *testing.T) {
}
}
+// TestRouletteRacetrackPanel checks that an enabled racetrack adds the call-bet
+// panel above the board, names all four call bets and the neighbours bet, and
+// keeps the same width and ASCII/Unicode guarantees as the board.
+func TestRouletteRacetrackPanel(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ art := renderRouletteLayout(false, true, mode, unicode)
+ plain := plainRoulette(art)
+ for _, want := range []string{"Voisins du Zero", "Tiers du Cylindre", "Orphelins", "Zero Spiel", "Neighbors (9)"} {
+ if !strings.Contains(plain, want) {
+ t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain)
+ }
+ }
+ for _, line := range strings.Split(art, "\n") {
+ if color.VisibleLen(line) > 78 {
+ t.Fatalf("mode=%s unicode=%v: line exceeds terminal width: %q", mode, unicode, plainRoulette(line))
+ }
+ if !unicode {
+ for _, r := range plainRoulette(line) {
+ if r > 127 {
+ t.Fatalf("mode=%s: non-ASCII rune %q in ASCII mode", mode, r)
+ }
+ }
+ }
+ }
+ }
+ }
+ // The panel comes from the snapshot flag, and a plain board omits it.
+ snap := &casino.RouletteSnapshot{Phase: "betting", Wheel: casino.WheelOrder(false), Racetrack: true}
+ if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") {
+ t.Fatal("racetrack snapshot did not draw the panel")
+ }
+ snap.Racetrack = false
+ if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") {
+ t.Fatal("plain snapshot drew the racetrack panel")
+ }
+}
+
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 7a22d9b..333e6a9 100644
--- a/internal/game/roulette_test.go
+++ b/internal/game/roulette_test.go
@@ -95,6 +95,45 @@ func TestRouletteCombinationTargetParsing(t *testing.T) {
}
}
+func TestRouletteRacetrackTargetParsing(t *testing.T) {
+ // A racetrack single-zero table accepts the call and neighbours syntaxes.
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}}
+ 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}
+ valid := [][]string{
+ {"10", "on", "voisins"},
+ {"10", "on", "voisins", "du", "zero"},
+ {"10", "on", "tiers"},
+ {"10", "on", "orphelins"},
+ {"10", "on", "zero", "spiel"},
+ {"10", "on", "neighbors", "17"},
+ {"10", "on", "neighbors", "of", "17"},
+ }
+ 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("args=%v: expected wallet failure, got %q", args, conn.data)
+ }
+ }
+ // The same names are unknown on a single-zero table without the option.
+ plain := &Game{Casino: casino.NewManager(1)}
+ plain.Casino.Join(1, casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}, "bob")
+ conn2 := &casinoTestConn{}
+ sess2 := &net.Session{Player: player.New("bob"), Conn: conn2}
+ plain.loggedInChars = map[string]*net.Session{"bob": sess2}
+ for _, args := range [][]string{{"10", "on", "voisins"}, {"10", "on", "neighbors", "17"}} {
+ conn2.data = nil
+ plain.executeBet(sess2, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn2.data), "Unknown roulette target") {
+ t.Fatalf("non-racetrack args=%v: expected rejection, got %q", args, conn2.data)
+ }
+ }
+}
+
func TestCasinoTestPortalAuthoredContract(t *testing.T) {
g, rooms, _ := loadVesta(t)
found := false
@@ -118,7 +157,7 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) {
if steps[1].Teleport != 6100 || rooms[6100] == nil {
t.Fatal("wrong casino destination")
}
- for _, id := range []int{6182, 6183} {
+ for _, id := range []int{6182, 6183, 6184} {
cfg := rooms[id].CasinoTables[0]
if err := cfg.Validate(); err != nil {
t.Fatal(err)
@@ -126,5 +165,8 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) {
if cfg.Game != casino.GameRoulette || cfg.Roulette.DoubleZero != (id == 6183) {
t.Fatal("wrong roulette rules", id)
}
+ if cfg.Roulette.Racetrack != (id == 6184) {
+ t.Fatal("wrong racetrack setting", id)
+ }
}
}