package casino import ( "fmt" "math/rand" "strconv" "strings" ) // RouletteConfig selects European (single zero) or American (double zero) rules. type RouletteConfig struct { DoubleZero bool `yaml:"double_zero,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 } 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 } // Returns the covered pockets and total-return multiplier (stake included). // Zero never belongs to an outside bet. func rouletteBet(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 } 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 "dozen1": hit = n <= 12 case "dozen2": hit = n >= 13 && n <= 24 case "dozen3": hit = n >= 25 case "column1": hit = n%3 == 1 case "column2": hit = n%3 == 2 case "column3": hit = n%3 == 0 } if hit { pockets = append(pockets, strconv.Itoa(n)) } } if len(pockets) == 0 { return nil, 0 } return pockets, 36 / len(pockets) } type roulettePlayer struct { Participant lastSpot string lastBet int } func (p *roulettePlayer) participantPtr() *Participant { return &p.Participant } type rouletteTable struct { tableCore[*roulettePlayer] rng *rand.Rand spinRemaining int lastResult *RouletteSnapshot } 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, Phase: string(t.phase)} for _, p := range t.players.ordered() { s.Players = append(s.Players, p.Name) } return s } func (t *rouletteTable) menuEvent(name string) Event { e := t.betMenuEvent(name, []MenuCommand{ {Command: "bet on ", Desc: "One wager per spin; numbers pay 35:1, outside bets 1:1, dozens/columns 2:1"}, {Command: "bet / rebet", Desc: "Repeat your previous wager"}, {Command: "table / sitout / stop", Desc: "View layout / sit out / leave after this spin"}, }) e.Menu.Heading = strings.Replace(e.Menu.Heading, "Next hand", "Next spin", 1) 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 } _, mult := rouletteBet(spot, t.Config.Roulette.DoubleZero) if mult == 0 { 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 { 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. 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) e.Snapshot = t.view() 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] s := t.view() s.Phase = "result" s.Pockets = [3]string{wheel[(index+len(wheel)-1)%len(wheel)], pocket, wheel[(index+1)%len(wheel)]} t.lastResult = s e := t.event("The ball lands in "+pocket+"!", true) e.Snapshot = s events := []Event{e} for _, p := range t.players.ordered() { if !p.HasBet { continue } covered, mult := rouletteBet(p.lastSpot, t.Config.Roulette.DoubleZero) payout := 0 result := "loss" var err error for _, n := range covered { if n == pocket { result = "win" payout, err = MulPayout(p.Wager.Total, float64(mult)) break } } // 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 := resultText(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) event.ShowBalance = !failed if result == "win" && !failed { event.Color = "casino_big_win" } 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() e := t.private(name, t.overviewLocked()+"\n"+t.seatStatusLocked(name)) e.Snapshot = t.view() 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 keeps ASCII views available to spectators and players // without coupling the engine to terminal color or card-art settings. type RouletteSnapshot struct { DoubleZero bool Phase string Pockets [3]string // physical wheel neighbors, not numerical neighbors Players []string } func (*RouletteSnapshot) snapshot() {} func (s *RouletteSnapshot) ASCII() string { if s.Phase == "result" { return fmt.Sprintf("+-----+-----+-----+\n| %-3s | %-3s | %-3s |\n| | o | |\n+-----+-----+-----+\n WINNER", s.Pockets[0], s.Pockets[1], s.Pockets[2]) } if s.Phase == string(phasePlaying) { bottom := " * " if s.DoubleZero { bottom = "00 " } return " .--[ 0 ]--.\n / * | * \\\n | * --+-- * |\n \\ * | * /\n '--[" + bottom + "]--'\n Spinning" } var b strings.Builder b.WriteString("ROULETTE | R=red B=black G=green\n[0 G]") if s.DoubleZero { b.WriteString(" [00 G]") } b.WriteString("\n") for row := 3; row >= 1; row-- { b.WriteString("|") for n := row; n <= 36; n += 3 { color := "B" if rouletteRed(n) { color = "R" } fmt.Fprintf(&b, "%2d%s|", n, color) } fmt.Fprintf(&b, " column%d\n", row) } b.WriteString("| dozen1 | dozen2 | dozen3 |\n| low (1-18) | even | red | black | odd | high (19-36) |\nTargets: 0-36") if s.DoubleZero { b.WriteString(", 00") } b.WriteString(", red, black, odd, even, low, high,\n dozen1/2/3, column1/2/3\nExample: bet 10 on 17 (one wager per spin)") return b.String() }