diff options
Diffstat (limited to 'internal/casino/roulette_racetrack.go')
| -rw-r--r-- | internal/casino/roulette_racetrack.go | 297 |
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) +} |
