diff options
| author | historia <[not public]> | 2026-09-29 13:28:06 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 13:28:06 -0400 |
| commit | 9803facd4368420a5dce3af59c835e13cb015cf5 (patch) | |
| tree | 4ecb75f9c88b16ae72d3573bef026d760eb8d7a8 /internal/casino | |
| parent | 13db3e50cb6e29eedbf25416e5284bf08b0adefb (diff) | |
| download | thehouseoficarus-9803facd4368420a5dce3af59c835e13cb015cf5.tar.gz | |
feat: roulette in domita casino
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/config_validate_test.go | 2 | ||||
| -rw-r--r-- | internal/casino/registry.go | 22 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 382 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 171 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 21 | ||||
| -rw-r--r-- | internal/casino/types.go | 2 |
6 files changed, 598 insertions, 2 deletions
diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go index 5e72054..b741d08 100644 --- a/internal/casino/config_validate_test.go +++ b/internal/casino/config_validate_test.go @@ -51,7 +51,7 @@ func TestTableConfigValidation(t *testing.T) { } } for name, c := range map[string]TableConfig{ - "unknown game": {ID: "table", Game: "roulette"}, + "unknown game": {ID: "table", Game: "not_a_game"}, "missing ID": {Game: GameBlackjack}, "negative timer": {ID: "table", Game: GameBlackjack, BettingTicks: -1}, "negative seats": {ID: "table", Game: GameBlackjack, MaxPlayers: -1}, diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 26acd8c..f27f7bd 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -1,6 +1,9 @@ package casino -import "math/rand" +import ( + "fmt" + "math/rand" +) // tableGameDef is the single registration point for a multiplayer table game. // A game's validation, rule defaults, and constructor live together here, so @@ -31,6 +34,23 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GameRoulette: { + validate: func(c TableConfig) error { + if c.Roulette.SpinTicks < 0 || c.Roulette.SpinTicks > maxSpinTicks { + return fmt.Errorf("roulette spin_ticks must be between 0 and %d", maxSpinTicks) + } + return nil + }, + normalize: func(c TableConfig) TableConfig { + if c.Roulette.SpinTicks == 0 { + c.Roulette.SpinTicks = 3 + } + return c + }, + ruleBlockName: "roulette", + hasRuleBlock: func(c TableConfig) bool { return c.Roulette != (RouletteConfig{}) }, + new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newRouletteTable(roomID, cfg, rng) }, + }, GameBlackjack: { validate: validateBlackjackTable, normalize: normalizeBlackjackTable, 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() +} diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go new file mode 100644 index 0000000..8defe1e --- /dev/null +++ b/internal/casino/roulette_test.go @@ -0,0 +1,171 @@ +package casino + +import ( + "math/rand" + "strconv" + "strings" + "testing" +) + +func TestRouletteRulesAndWheels(t *testing.T) { + for _, double := range []bool{false, true} { + wheel := rouletteWheel(double) + seen := map[string]bool{} + want := 37 + if double { + want = 38 + } + if len(wheel) != want { + t.Fatal("wheel size", len(wheel)) + } + for _, pocket := range wheel { + if seen[pocket] { + t.Fatal("duplicate", pocket) + } + seen[pocket] = true + } + for n := 0; n <= 36; n++ { + if !seen[strconv.Itoa(n)] { + t.Fatal("missing", n) + } + } + if seen["00"] != double { + t.Fatal("wrong double zero") + } + for _, spot := range strings.Fields("red black even odd low high dozen1 dozen2 dozen3 column1 column2 column3") { + pockets, mult := rouletteBet(spot, double) + if len(pockets)*mult != 36 { + t.Fatalf("%s coverage/payout %d/%d", spot, len(pockets), mult) + } + for _, p := range pockets { + if p == "0" || p == "00" { + t.Fatal("zero in outside bet") + } + } + } + pockets, mult := rouletteBet("00", double) + if double && (len(pockets) != 1 || mult != 36) || !double && mult != 0 { + t.Fatal("00 rules") + } + } + for _, spot := range []string{"-1", "37", "000", "01", "banana", ""} { + if _, m := rouletteBet(spot, true); m != 0 { + t.Fatal("accepted", spot) + } + } +} +func rouletteFixture() *rouletteTable { + return newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{SpinTicks: 2}}, rand.New(rand.NewSource(7))) +} +func TestRouletteTimedPhasesAndSettlement(t *testing.T) { + for _, double := range []bool{false, true} { + table := rouletteFixture() + table.Config.Roulette.DoubleZero = double + wheel := rouletteWheel(double) + index := rand.New(rand.NewSource(7)).Intn(len(wheel)) + table.join("a") + w := &testWallet{chips: 100} + table.bet("a", wheel[index], 10, w) + table.bet("a", "red", 10, w) + if w.chips != 90 || table.phase != phaseBetting { + t.Fatal("duplicate or early spin") + } + table.tick() + if table.phase != phaseBetting { + t.Fatal("betting ended early") + } + events := table.tick() + if table.phase != phasePlaying || len(events) == 0 { + t.Fatal("no spinning phase") + } + table.join("b") + other := &testWallet{chips: 100} + table.bet("b", "red", 10, other) + if other.chips != 100 { + t.Fatal("bet while spinning") + } + table.tick() + if w.paid != 0 { + t.Fatal("settled early") + } + table.action("a", "leave_after") + events = table.tick() + if w.paid != 360 || len(w.open) != 0 || table.hasParticipant("a") { + t.Fatalf("bad settlement: %+v", w) + } + s := events[0].Snapshot.(*RouletteSnapshot) + if s.Pockets != [3]string{wheel[(index+len(wheel)-1)%len(wheel)], wheel[index], wheel[(index+1)%len(wheel)]} { + t.Fatal("incorrect wheel neighbors") + } + table.tick() + table.refundAll() + if w.paid != 360 { + t.Fatal("double paid") + } + } +} +func TestRouletteRefundAndPersistenceFailure(t *testing.T) { + for _, spinning := range []bool{false, true} { + table := rouletteFixture() + table.join("a") + w := &testWallet{chips: 100} + table.bet("a", "red", 10, w) + if spinning { + table.tick() + table.tick() + } + table.markDisconnected("a") + table.refundAll() + table.tick() + if w.chips != 100 || len(w.open) != 0 || w.paid != 0 { + t.Fatalf("disconnect refund: %+v", w) + } + } + table := rouletteFixture() + table.join("a") + w := &failingPayoutWallet{testWallet: testWallet{chips: 100}} + table.bet("a", "red", 10, w) + for i := 0; i < 4; i++ { + table.tick() + } + if len(w.open) != 1 { + t.Fatal("failed settlement consumed stake") + } + table.refundAll() + table.refundAll() + if w.chips != 100 || len(w.open) != 0 { + t.Fatal("failed settlement not refunded once") + } +} +func TestRouletteViewsAndConfig(t *testing.T) { + for _, double := range []bool{false, true} { + s := &RouletteSnapshot{DoubleZero: double, Phase: "playing"} + wheel := s.ASCII() + if strings.Contains(wheel, "00") != double || strings.Count(wheel, "[ 0 ]") != 1 { + t.Fatal("wrong wheel zero pockets", wheel) + } + s.Phase = "betting" + layout := s.ASCII() + if !strings.Contains(layout, "column3") || !strings.Contains(layout, "36R") || strings.Contains(layout, "[00 G]") != double { + t.Fatal("bad layout", layout) + } + s.Phase = "result" + s.Pockets = [3]string{"26", "0", "32"} + if !strings.Contains(s.ASCII(), "| 26 | 0 | 32 |") { + t.Fatal(s.ASCII()) + } + } + cfg := TableConfig{ID: "r", Game: GameRoulette} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + cfg.Roulette.SpinTicks = -1 + if cfg.Validate() == nil { + t.Fatal("invalid ticks accepted") + } + cfg.Roulette.SpinTicks = 2 + cfg.Game = GameBlackjack + if cfg.Validate() == nil { + t.Fatal("foreign rule block accepted") + } +} diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 9543d91..48ea46e 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -5,7 +5,28 @@ import ( "strings" ) +// RulesText is the shared rules view for table commands and room fixtures. +func (c TableConfig) RulesText() string { + switch c.Game { + case GameBlackjack: + return c.BlackjackRulesText() + case GameBaccarat: + return c.BaccaratRulesText() + case GameRoulette: + s := &RouletteSnapshot{DoubleZero: c.Roulette.DoubleZero, Phase: "betting"} + return c.Label() + "\n" + s.ASCII() + "\nStraight numbers pay 35:1; dozens/columns 2:1; other outside bets 1:1.\nZero pockets lose all outside bets. One wager per spin. Use help roulette." + default: + return c.Label() + } +} + func (c TableConfig) Label() string { + if c.Game == GameRoulette { + if c.Roulette.DoubleZero { + return "Roulette / Double zero" + } + return "Roulette / Single zero" + } if c.Game != GameBaccarat { return gameTitle(c.Game) } diff --git a/internal/casino/types.go b/internal/casino/types.go index 4bbc0a8..518af02 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -17,6 +17,7 @@ const ( GameSlots GameName = "slots" GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" + GameRoulette GameName = "roulette" ) type BlackjackConfig struct { @@ -127,6 +128,7 @@ type TableConfig struct { MaxBet int `yaml:"max_bet,omitempty"` Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` Baccarat BaccaratConfig `yaml:"baccarat,omitempty"` + Roulette RouletteConfig `yaml:"roulette,omitempty"` } type MachineConfig struct { |
