diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/registry.go | 3 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 32 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack.go | 297 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack_test.go | 265 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 3 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 17 |
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) |
