aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/roulette.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-29 13:28:06 -0400
committerhistoria <[not public]>2026-09-29 13:28:06 -0400
commit9803facd4368420a5dce3af59c835e13cb015cf5 (patch)
tree4ecb75f9c88b16ae72d3573bef026d760eb8d7a8 /internal/casino/roulette.go
parent13db3e50cb6e29eedbf25416e5284bf08b0adefb (diff)
downloadthehouseoficarus-9803facd4368420a5dce3af59c835e13cb015cf5.tar.gz
feat: roulette in domita casino
Diffstat (limited to 'internal/casino/roulette.go')
-rw-r--r--internal/casino/roulette.go382
1 files changed, 382 insertions, 0 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
new file mode 100644
index 0000000..be8966c
--- /dev/null
+++ b/internal/casino/roulette.go
@@ -0,0 +1,382 @@
+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 <amount> on <target>", 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()
+}