package casino import ( "fmt" "math/rand" "strconv" "strings" ) // 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"` } var europeanWheel = strings.Fields("0 32 15 19 4 21 2 25 17 34 6 27 13 36 11 30 8 23 10 5 24 16 33 1 20 14 31 9 22 18 29 7 28 12 35 3 26") var americanWheel = strings.Fields("0 28 9 26 30 11 7 20 32 17 5 22 34 15 3 24 36 13 1 00 27 10 25 29 12 8 19 31 18 6 21 33 16 4 23 35 14 2") func rouletteWheel(double bool) []string { if double { return americanWheel } return europeanWheel } // WheelOrder returns a copy of the pocket order (clockwise from the top) for // presentation layers that draw the wheel themselves. func WheelOrder(double bool) []string { return append([]string(nil), rouletteWheel(double)...) } func rouletteRed(n int) bool { switch n { case 1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36: return true } return false } // normalizeRouletteSpot maps legacy bet names onto the labels shown on the // board. The old dozen/column spellings stay accepted so existing habits and // older help text keep working. func normalizeRouletteSpot(spot string) string { switch spot { case "dozen1": return "1to12" case "dozen2": return "13to24" case "dozen3": return "25to36" case "column1": return "row1" case "column2": return "row2" case "column3": return "row3" } return spot } // Returns the covered pockets and total-return multiplier (stake included). // Zero never belongs to an outside bet. Combination bets (splits, streets, // corners, six-lines, trios and the basket) are parsed from a compact number // list: the leading keyword is optional because the covered set uniquely // identifies the bet. func rouletteBet(spot string, double bool) ([]string, int) { spot = normalizeRouletteSpot(spot) if pockets, mult := rouletteStraightBet(spot, double); mult > 0 { return pockets, mult } if pockets, mult := rouletteOutsideBet(spot); mult > 0 { return pockets, mult } return rouletteCombinationBet(spot, double) } // rouletteStraightBet covers a single pocket: any number, 0, or 00 (American // tables only). It pays 35:1, a 36x total return. func rouletteStraightBet(spot string, double bool) ([]string, int) { if spot == "00" { if double { return []string{spot}, 36 } return nil, 0 } if n, err := strconv.Atoi(spot); err == nil && n >= 0 && n <= 36 && strconv.Itoa(n) == spot { return []string{spot}, 36 } return nil, 0 } // rouletteOutsideBet covers the even-money, dozen, and row wagers, whose // pocket counts all divide 36 exactly, so the return is 36/len. func rouletteOutsideBet(spot string) ([]string, int) { var pockets []string for n := 1; n <= 36; n++ { hit := false switch spot { case "red": hit = rouletteRed(n) case "black": hit = !rouletteRed(n) case "odd": hit = n%2 == 1 case "even": hit = n%2 == 0 case "low": hit = n <= 18 case "high": hit = n >= 19 case "1to12": hit = n <= 12 case "13to24": hit = n >= 13 && n <= 24 case "25to36": hit = n >= 25 case "row1": hit = n%3 == 0 case "row2": hit = n%3 == 2 case "row3": hit = n%3 == 1 } if hit { pockets = append(pockets, strconv.Itoa(n)) } } if len(pockets) == 0 { return nil, 0 } return pockets, 36 / len(pockets) } // rouletteComboKeywords maps the accepted names for a combination bet onto // its canonical kind. Several names share a kind ("square" is a corner, // "sixline"/"double street" is a line). var rouletteComboKeywords = map[string]string{ "split": "split", "street": "street", "corner": "corner", "square": "corner", "line": "line", "sixline": "line", "six-line": "line", "doublestreet": "line", "double-street": "line", "trio": "trio", "basket": "basket", } // Payout returns are 36/covered for every combination except the five-number // American basket, which pays a deliberately short 6:1 (7x total). func rouletteCombinationBet(spot string, double bool) ([]string, int) { kind, nums, ok := parseRouletteCombination(spot, double) if !ok { return nil, 0 } if kind == "" { kind = inferRouletteCombination(nums) } switch kind { case "split": if len(nums) == 2 && rouletteAdjacent(nums[0], nums[1], double) { return nums, 18 } case "street": if rouletteIsStreet(nums) { return nums, 12 } case "corner": if rouletteIsCorner(nums, double) { return nums, 9 } case "line": if rouletteIsLine(nums) { return nums, 6 } case "trio": if rouletteIsTrio(nums, double) { return nums, 12 } case "basket": if pockets, mult, valid := rouletteBasket(nums, double); valid { return pockets, mult } } return nil, 0 } // parseRouletteCombination strips an optional leading keyword and converts the // remaining number tokens (separated by spaces, dashes, slashes, commas or // backslashes) into canonical pocket names. Duplicate numbers are rejected. func parseRouletteCombination(spot string, double bool) (string, []string, bool) { fields := strings.Fields(strings.ToLower(spot)) if len(fields) == 0 { return "", nil, false } kind := "" switch fields[0] { case "split", "street", "corner", "square", "line", "sixline", "six-line", "doublestreet", "double-street", "trio", "basket": kind = fields[0] fields = fields[1:] case "six": if len(fields) > 1 && fields[1] == "line" { kind, fields = "sixline", fields[2:] } case "double": if len(fields) > 1 && fields[1] == "street" { kind, fields = "doublestreet", fields[2:] } } if canonical, ok := rouletteComboKeywords[kind]; ok { kind = canonical } else if kind != "" { return "", nil, false } if kind == "basket" && len(fields) == 0 { return "basket", nil, true } if len(fields) == 0 { return "", nil, false } raw := strings.NewReplacer("-", " ", "/", " ", ",", " ", "\\", " ").Replace(strings.Join(fields, " ")) seen := map[string]bool{} var nums []string for _, tok := range strings.Fields(raw) { pocket, ok := roulettePocketToken(tok, double) if !ok || seen[pocket] { return "", nil, false } seen[pocket] = true nums = append(nums, pocket) } if len(nums) == 0 { return "", nil, false } return kind, nums, true } // roulettePocketToken canonicalizes one number token: a plain 0-36 value or // 00, rejecting non-canonical spellings such as "01", "+1" or "000". func roulettePocketToken(tok string, double bool) (string, bool) { if tok == "00" { return "00", double } n, err := strconv.Atoi(tok) if err != nil || n < 0 || n > 36 || strconv.Itoa(n) != tok { return "", false } return strconv.Itoa(n), true } // inferRouletteCombination identifies a bare number list by its size. Adjacency // and geometry are validated after this, so an ambiguous count still fails. func inferRouletteCombination(nums []string) string { switch len(nums) { case 2: return "split" case 3: if rouletteIsStreet(nums) { return "street" } return "trio" case 4: return "corner" case 5: return "basket" case 6: return "line" } return "" } // rouletteNumberCell maps 1-36 onto its column and row on the felt: column // 0-11 left to right, row 0-2 bottom to top (row 0 holds 1,4,7,...). func rouletteNumberCell(n string) (col, row int, ok bool) { v, err := strconv.Atoi(n) if err != nil || v < 1 || v > 36 { return 0, 0, false } return (v - 1) / 3, (v - 1) % 3, true } // rouletteAdjacent reports whether two pockets share an edge: horizontally // (same row, neighboring columns) or vertically (same column, neighboring // rows). Zero and double zero use the felt's special zero edge. func rouletteAdjacent(a, b string, double bool) bool { if a == "0" || a == "00" || b == "0" || b == "00" { return rouletteZeroAdjacent(a, b, double) } ac, ar, ok := rouletteNumberCell(a) if !ok { return false } bc, br, ok := rouletteNumberCell(b) if !ok { return false } dc, dr := ac-bc, ar-br if dc < 0 { dc = -dc } if dr < 0 { dr = -dr } return (dc == 1 && dr == 0) || (dc == 0 && dr == 1) } // European zero touches 1, 2 and 3. On the American layout, 0 // touches 1 and 2; 00 touches 2 and 3. The zeros also touch each other. func rouletteZeroAdjacent(a, b string, double bool) bool { if b == "0" || b == "00" { a, b = b, a } if !double { return a == "0" && (b == "1" || b == "2" || b == "3") } return (a == "0" && (b == "00" || b == "1" || b == "2")) || (a == "00" && (b == "0" || b == "2" || b == "3")) } func rouletteIsStreet(nums []string) bool { if len(nums) != 3 { return false } set := map[int]bool{} for _, s := range nums { v, err := strconv.Atoi(s) if err != nil { return false } set[v] = true } if len(set) != 3 { return false } for n := 1; n <= 34; n += 3 { if set[n] && set[n+1] && set[n+2] { return true } } return false } func rouletteIsCorner(nums []string, double bool) bool { if len(nums) != 4 { return false } // The European "first four" (0,1,2,3) is the only zero corner. if !double && rouletteIsFirstFour(nums) { return true } minCol, maxCol, minRow, maxRow := 12, -1, 3, -1 cells := map[[2]int]bool{} for _, s := range nums { c, r, ok := rouletteNumberCell(s) if !ok { return false } cells[[2]int{c, r}] = true minCol, maxCol = min(minCol, c), max(maxCol, c) minRow, maxRow = min(minRow, r), max(maxRow, r) } return len(cells) == 4 && maxCol-minCol == 1 && maxRow-minRow == 1 } func rouletteIsFirstFour(nums []string) bool { if len(nums) != 4 { return false } set := map[string]bool{} for _, s := range nums { set[s] = true } return set["0"] && set["1"] && set["2"] && set["3"] } // A six-line is two neighboring columns, i.e. six numbers across all three // rows of two adjacent streets. func rouletteIsLine(nums []string) bool { if len(nums) != 6 { return false } cols := map[int]bool{} set := map[int]bool{} for _, s := range nums { c, _, ok := rouletteNumberCell(s) if !ok { return false } v, _ := strconv.Atoi(s) cols[c] = true set[v] = true } if len(cols) != 2 || len(set) != 6 { return false } var c1, c2 int first := true for c := range cols { if first { c1, first = c, false } else { c2 = c } } if c1 > c2 { c1, c2 = c2, c1 } return c2-c1 == 1 } // Trios cover touching zero-edge intersections, not arbitrary selections // from the zeros and the first street. func rouletteIsTrio(nums []string, double bool) bool { if len(nums) != 3 { return false } set := map[string]bool{} for _, n := range nums { set[n] = true } if double { return (set["0"] && set["1"] && set["2"]) || (set["0"] && set["00"] && set["2"]) || (set["00"] && set["2"] && set["3"]) } return set["0"] && set["2"] && (set["1"] || set["3"]) } // The basket is the American five-number 0/00/1/2/3 bet, which pays a short // 6:1. On a single-zero felt the name covers the European first four instead. // An empty list means the bare "basket" keyword. func rouletteBasket(nums []string, double bool) ([]string, int, bool) { if len(nums) == 0 { if double { return []string{"0", "00", "1", "2", "3"}, 7, true } return []string{"0", "1", "2", "3"}, 9, true } if double { set := map[string]bool{} for _, s := range nums { set[s] = true } if len(nums) != 5 { return nil, 0, false } for _, need := range []string{"0", "00", "1", "2", "3"} { if !set[need] { return nil, 0, false } } return []string{"0", "00", "1", "2", "3"}, 7, true } if rouletteIsFirstFour(nums) { return []string{"0", "1", "2", "3"}, 9, true } return nil, 0, false } type roulettePlayer struct { Participant lastSpot string lastBet int } func (p *roulettePlayer) participantPtr() *Participant { return &p.Participant } // Keep a small, table-local window of completed spins (oldest first). const rouletteHistoryLimit = 50 type rouletteTable struct { tableCore[*roulettePlayer] rng *rand.Rand spinRemaining int lastResult *RouletteSnapshot history []string } func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable { cfg = cfg.Normalize() return &rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{RoomID: room, Config: cfg}), rng: rng} } func (t *rouletteTable) view() *RouletteSnapshot { s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero), History: append([]string(nil), t.history...)} for _, p := range t.players.ordered() { s.Players = append(s.Players, p.Name) } return s } func (t *rouletteTable) menuEvent(name string) Event { commands := []MenuCommand{} p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { commands = append(commands, MenuCommand{Command: "bet on ", Desc: "Place one wager; targets: list"}) if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) } } if p != nil { commands = append(commands, MenuCommand{Command: "sitout / stop", Desc: "Sit out / leave when your wager settles"}) } commands = append(commands, MenuCommand{Command: "table", Desc: "View the layout"}) if p != nil { commands = withTableHints(commands, t.wagerRows()) } e := t.betMenuEvent(name, commands) e.Snapshot = t.view() return e } func (t *rouletteTable) join(name string) []Event { t.mu.Lock() defer t.mu.Unlock() if t.players.has(name) { return []Event{t.menuEvent(name)} } if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That roulette table is full.")} } e := t.addSeatLocked(name, &roulettePlayer{Participant: Participant{Name: name, Connected: true}}) return []Event{e, t.menuEvent(name)} } func (t *rouletteTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return nil } if p.HasBet { return []Event{t.private(name, "Your roulette wager is committed until this spin ends.")} } t.removeSeatLocked(name) return t.closeIfEmptyLocked([]Event{t.playerAction(name, name+" stands up.", "You stand up.")}) } func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't playing roulette.")} } if t.phase == phasePlaying { return []Event{t.private(name, "No more bets! The wheel is spinning.")} } if p.HasBet { return []Event{t.private(name, "You have already bet this spin.")} } if spot == "" { spot = p.lastSpot } spot = normalizeRouletteSpot(spot) 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 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) if err != nil { return []Event{t.private(name, walletErrorMessage(err))} } p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))} // Always offer a real betting phase, even with only one player. The // board is not attached here: it is sent once per round by the menu event // (join, settlement, or an explicit "table"), so placing a wager never // redraws it. if t.phase == phaseWaiting { t.phase = phaseBetting t.window.open(t.Config.BettingTicks) e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true) events = append(events, e) } return events } func (t *rouletteTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if t.phase == phasePlaying { t.spinRemaining-- if t.spinRemaining <= 0 { return t.settle() } return nil } if t.phase != phaseBetting { return nil } // Unlike card games, all seats betting does not close roulette early. t.window.timer.tick() if !t.window.timer.expired() { return nil } anyBet := false for _, p := range t.players.ordered() { anyBet = anyBet || p.HasBet } if !anyBet { t.resetRound() return nil } t.phase = phasePlaying t.spinRemaining = t.Config.Roulette.SpinTicks e := t.event("No more bets! The roulette wheel spins...", true) e.Snapshot = t.view() return []Event{e} } func (t *rouletteTable) settle() []Event { wheel := rouletteWheel(t.Config.Roulette.DoubleZero) index := t.rng.Intn(len(wheel)) pocket := wheel[index] t.history = append(t.history, pocket) if len(t.history) > rouletteHistoryLimit { copy(t.history, t.history[len(t.history)-rouletteHistoryLimit:]) t.history = t.history[:rouletteHistoryLimit] } s := t.view() s.Phase = "result" // Show the winning pocket with two physical neighbours on each side so // the result reads like a slice of the real wheel. n := len(wheel) s.Pockets = [5]string{ wheel[(index-2+n)%n], wheel[(index-1+n)%n], pocket, wheel[(index+1)%n], wheel[(index+2)%n], } t.lastResult = s e := t.event("The ball lands in {casino_emphasis}"+pocket+"{/}!", true) e.Snapshot = s events := []Event{e} for _, p := range t.players.ordered() { if !p.HasBet { continue } wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) payout := 0 result := "loss" var err error if resolved { if share, ok := wager.covers(pocket); ok { result = "win" payout, err = share.payout(p.Wager.Total) } } // PayCredits supports a zero return and atomically consumes a losing // stake too, surfacing persistence errors instead of announcing success. failed := err != nil if !failed { failed = p.Wallet.PayCredits(payout, p.Wager.ID) != nil } text := resultTextLabel(p.lastSpot, result, p.Wager.Total, payout) public := p.Name + " " + result + "." if failed { text = payoutFailedMessage() public = p.Name + "'s settlement is pending recovery." } event := t.playerAction(p.Name, public, text) // Returning players get their balance in the next-spin menu. Only // departures/sit-outs need a balance attached to the settlement. event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) if !failed { if result == "win" { event.Color = "casino_result_win" } else { event.Color = "casino_result_loss" } } events = append(events, event) } t.resetRound() events = append(events, t.finishControlsLocked()...) for _, p := range t.players.ordered() { if p.Connected && !p.SittingOut { events = append(events, t.menuEvent(p.Name)) } } return events } func (t *rouletteTable) resetRound() { t.phase = phaseWaiting t.window.close() t.spinRemaining = 0 clearBets(t.players.participants()) } func (t *rouletteTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() if events, ok := t.controlLocked(name, action); ok { return events } return []Event{t.private(name, "Roulette has no player actions after betting.")} } func (t *rouletteTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if p := t.players.get(name); p != nil && p.lastBet > 0 { return p.lastSpot, p.lastBet } return "", t.Config.MinBet } func (t *rouletteTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() message := t.overviewLocked() if text := t.seatStatusLocked(name); text != "" { message += "\n" + text } e := t.menuEvent(name) e.Message = message e.Menu.Heading = message e.Menu.ShowBalance = false events := []Event{e} if t.phase == phaseWaiting && t.lastResult != nil { last := t.private(name, "Last spin:") last.Snapshot = t.lastResult events = append(events, last) } return events } func (t *rouletteTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return nil } // No outcome has been sampled or revealed during the spin. Disconnect // refunds are safe, just as pre-deal baccarat refunds are. var events []Event if p.Wallet != nil { if _, _, err := p.Wallet.RefundOutstanding(); err != nil { events = append(events, t.private(name, "Your stake remains pending for startup recovery.")) } } t.removeSeatLocked(name) if t.players.count() == 0 { t.resetRound() } return t.closeIfEmptyLocked(append(events, t.event(name+" stands up.", true))) } func (t *rouletteTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() events := t.refundAllLocked() t.resetRound() return events } // RouletteSnapshot describes the view without coupling the engine to terminal // colors, Unicode preferences, or card-art settings. type RouletteSnapshot struct { DoubleZero bool // 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 [5]string // winning pocket and two physical wheel neighbours each side 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. Wheel []string // History is a detached copy of the last 50 completed spins, oldest first. // It is table-local, survives round resets, and is not persisted on restart. History []string } func (*RouletteSnapshot) snapshot() {}