aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/roulette_racetrack.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/roulette_racetrack.go')
-rw-r--r--internal/casino/roulette_racetrack.go297
1 files changed, 297 insertions, 0 deletions
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)
+}