aboutsummaryrefslogtreecommitdiff
path: root/internal
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
parent13db3e50cb6e29eedbf25416e5284bf08b0adefb (diff)
downloadthehouseoficarus-9803facd4368420a5dce3af59c835e13cb015cf5.tar.gz
feat: roulette in domita casino
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/config_validate_test.go2
-rw-r--r--internal/casino/registry.go22
-rw-r--r--internal/casino/roulette.go382
-rw-r--r--internal/casino/roulette_test.go171
-rw-r--r--internal/casino/table_ux.go21
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/game/blackjack_rules.go10
-rw-r--r--internal/game/casino.go2
-rw-r--r--internal/game/casino_ux.go13
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/roulette_test.go78
11 files changed, 692 insertions, 13 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 {
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index 61aab47..b56d3ee 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -16,6 +16,10 @@ func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameBaccarat)
}
+func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
+ return g.lookCardTable(sess, target, casino.GameRoulette)
+}
+
func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameName) bool {
room, err := g.World.LoadRoom(sess.Player.RoomID)
if err != nil {
@@ -35,11 +39,7 @@ func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameN
if !ok {
return false
}
- if game == casino.GameBaccarat {
- sess.WriteLine(table.Config.BaccaratRulesText())
- } else {
- sess.WriteLine(table.Config.BlackjackRulesText())
- }
+ sess.WriteLine(table.Config.RulesText())
return true
}
return false
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 579371c..857a6a8 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -673,6 +673,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
size = 1
}
switch s := snapshot.(type) {
+ case *casino.RouletteSnapshot:
+ return s.ASCII()
case *casino.SlotSnapshot:
return renderSlotSnapshot(s, mode, unicode)
case *casino.BlackjackSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index fdd4533..d5ed67e 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -107,10 +107,7 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
return
}
if len(args) > 0 && args[0] == "rules" {
- text := t.Config.BlackjackRulesText()
- if t.Config.Game == casino.GameBaccarat {
- text = t.Config.BaccaratRulesText()
- }
+ text := t.Config.RulesText()
sess.WriteLine(g.colorize(sess, "casino_action", text))
return
}
@@ -154,7 +151,7 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) {
}
c, ok := resolveCasinoChoice(choices, input)
if !ok {
- sess.WriteLine("Choose a table to watch: watch blackjack or watch baccarat.")
+ sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, or watch roulette.")
return
}
t, ok := g.casinoTable(sess.Player.RoomID, c.address)
@@ -181,6 +178,12 @@ func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool {
// Settlement may have removed a queued departure before these events
// are delivered. Their own final cards must still be visible.
switch s := event.Snapshot.(type) {
+ case *casino.RouletteSnapshot:
+ for _, name := range s.Players {
+ if name == sess.Player.Name {
+ return true
+ }
+ }
case *casino.BlackjackSnapshot:
for _, p := range s.Players {
if p.Name == sess.Player.Name {
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 76836d6..8d970e4 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) {
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) {
return
}
diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go
new file mode 100644
index 0000000..0487b0b
--- /dev/null
+++ b/internal/game/roulette_test.go
@@ -0,0 +1,78 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestRouletteNumericTargetsAndRendering(t *testing.T) {
+ for _, double := range []bool{false, true} {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double}}
+ g.Casino.Join(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ for _, target := range []string{"0", "17", "00", "red", "column2"} {
+ conn.data = nil
+ g.executeBet(sess, []string{"10", "on", target}, "bet 10 on "+target)
+ expected := "You don't have enough chips and credits"
+ if target == "00" && !double {
+ expected = "Unknown roulette target"
+ }
+ if !strings.Contains(string(conn.data), expected) {
+ t.Fatalf("target=%s double=%v: %s", target, double, conn.data)
+ }
+ }
+ conn.data = nil
+ g.executeTableStatus(sess, []string{"rules"}, "table rules")
+ if !strings.Contains(string(conn.data), "Straight numbers pay 35:1") || strings.Contains(string(conn.data), "Blackjack") {
+ t.Fatal("wrong rules", string(conn.data))
+ }
+ snapshot := &casino.RouletteSnapshot{DoubleZero: double, Phase: "playing", Players: []string{"alice"}}
+ if got := g.renderCasinoSnapshot(sess, snapshot); got != snapshot.ASCII() {
+ t.Fatal("snapshot not rendered", got)
+ }
+ if !g.seesCasinoDetails(sess, casino.Event{RoomID: 1, TableID: "roulette", Snapshot: snapshot}) {
+ t.Fatal("player cannot see wheel")
+ }
+ }
+}
+
+func TestCasinoTestPortalAuthoredContract(t *testing.T) {
+ g, rooms, _ := loadVesta(t)
+ found := false
+ for _, obj := range rooms[1001].Objects {
+ found = found || obj.ID == "casino_test_portal"
+ }
+ if !found {
+ t.Fatal("portal missing from starting room")
+ }
+ obj, err := g.ObjectStore.Load("casino_test_portal")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(obj.OnUse) != 1 || len(obj.OnUse[0].Steps) != 2 {
+ t.Fatal("unexpected portal sequence")
+ }
+ steps := obj.OnUse[0].Steps
+ if len(steps[0].DropTable) != 1 || steps[0].DropTable[0].ItemID != "chips" || steps[0].DropTable[0].Quantity != 1000 {
+ t.Fatal("portal does not grant 1000 chips")
+ }
+ if steps[1].Teleport != 6100 || rooms[6100] == nil {
+ t.Fatal("wrong casino destination")
+ }
+ for _, id := range []int{6182, 6183} {
+ cfg := rooms[id].CasinoTables[0]
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Game != casino.GameRoulette || cfg.Roulette.DoubleZero != (id == 6183) {
+ t.Fatal("wrong roulette rules", id)
+ }
+ }
+}