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 | |
| parent | 13db3e50cb6e29eedbf25416e5284bf08b0adefb (diff) | |
| download | thehouseoficarus-9803facd4368420a5dce3af59c835e13cb015cf5.tar.gz | |
feat: roulette in domita casino
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | building_guide/roulette.md | 62 | ||||
| -rw-r--r-- | data/help/roulette.yaml | 31 | ||||
| -rw-r--r-- | data/objects/casino_test_portal.yaml | 14 | ||||
| -rw-r--r-- | data/rooms/casino/6100.yaml | 1 | ||||
| -rw-r--r-- | data/rooms/casino/6102.yaml | 2 | ||||
| -rw-r--r-- | data/rooms/casino/6182.yaml | 16 | ||||
| -rw-r--r-- | data/rooms/casino/6183.yaml | 14 | ||||
| -rw-r--r-- | data/rooms/intro/1001.yaml | 1 | ||||
| -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 | ||||
| -rw-r--r-- | internal/game/blackjack_rules.go | 10 | ||||
| -rw-r--r-- | internal/game/casino.go | 2 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 13 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 78 |
20 files changed, 834 insertions, 20 deletions
@@ -46,7 +46,7 @@ There is not much here yet. | Construction | Build a player-owned house for fast travel or to show off rare items. | | Agility | Get more stamina and shorter wait times when AFK travelling with APS. | -# Building +# Building / Playing Install Go then @@ -65,12 +65,6 @@ The `thoi` binary checks for a file named config.yaml in `$XDG_CONFIG_HOME/thoi` The game is designed around 600ms ticks, but you can change the `tick_length` here. You can also set up telnet, SSL telnet, HTTP, and HTTPS on different ports here. For the secure options can provide a private key and certificate. Use something like [certbot](https://certbot.eff.org/). If you enable HTTPS/Telnet TLS without providing certs, a self-signed cert will be generated and used for you. -# AI Disclosure - -The THOI game engine and tools are written with LLM tools. - -LLMs do not generate any creative content (e.g. story, characters, rooms, descriptions, quests) besides dev-test examples. I personally prefer mediocre solo dev writing to AI filler. - # License 0BSD diff --git a/building_guide/roulette.md b/building_guide/roulette.md new file mode 100644 index 0000000..205e044 --- /dev/null +++ b/building_guide/roulette.md @@ -0,0 +1,62 @@ +# Roulette and casino extension readiness + +The casino has a usable extension seam: `tableGame`, generic `tableCore`, +registered constructors/config validation in `registry.go`, structured snapshots, +shared participation/control routing, and durable per-stake wallet handles. +Roulette uses those seams rather than adding another command dispatcher. + +Example table (one game of each type per room remains the manager's rule): + +```yaml +casino_tables: + - id: roulette_single + game: roulette + min_bet: 1 + max_bet: 10000 + betting_ticks: 15 + roulette: + double_zero: false + spin_ticks: 4 +``` + +`double_zero: true` selects the American 38-pocket wheel; otherwise the +European 37-pocket wheel is used. Spin ticks default to 3; betting ticks use +the shared default. Ticks follow the server's configured tick length. +The wheel order is authentic and the outcome is selected uniformly only when +spinning finishes. The spinning illustration is intentionally schematic. + +Initial scope is **one wager per player per spin**, covering straight-up and +outside bets (colors, parity, high/low, dozens, columns). Multiple simultaneous +bets and split/street/corner bets are future work, not silently accepted syntax. +All returns are checked before debiting a wager. Both winning and losing stakes +settle through `PayCredits(return, stakeID)`, including a zero return for losses. +Failures leave the stake pending and do not claim that payment succeeded. +Shutdown and eviction refund outstanding stakes and reset the round. +Disconnect refunds are safe because no partial outcome is sampled or exposed. + +## Assessment and boundaries + +This is a sound foundation for more testing-stage table games, not a blanket +production-readiness certification. New games must test payout math, overflow, +phase boundaries, disconnects, queued departure, refunds, persistence failure, +and spectator output. Shared card-game loss settlement still uses the void +`ResolveStakesByIDs` API: it preserves failed stakes for recovery but cannot +report that failure to its caller. Roulette avoids that limitation with the +existing error-returning payment API. Uniform per-stake error-returning +settlement would be a worthwhile follow-up for older games. + +Multiple concurrent wagers need their own explicit ledger/limit model instead +of reusing the single `Participant.Wager`. Multiple same-game tables in one room +would also require changing current addressing/validation assumptions. + +## Testing shortcut + +`data/objects/casino_test_portal.yaml` is placed in starting room 1001. +`use casino` gives 1000 chips and teleports to vestibule 6100. It is intentionally +repeatable and uses ordinary trigger/drop/teleport machinery. Keep an inventory +slot free (ordinary trigger drops fall to the ground when inventory is full). +Remove the room's `casino_test_portal` reference before production: this grants +unlimited test funds and bypasses travel/progression. No account roles are needed. + +Single-zero room 6182 and double-zero room 6183 are north of ground-floor +landing 6102. `help roulette` describes player syntax and supported bets. diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml new file mode 100644 index 0000000..c1b6a0d --- /dev/null +++ b/data/help/roulette.yaml @@ -0,0 +1,31 @@ +name: roulette +category: Interaction +description: | + Play roulette in Domita's House of Icarus casino. + From the vestibule: north, up, north reaches single zero; + another north reaches double zero. + + play roulette Join the table + table Show layout, phase, and last result + bet 10 on 17 Bet on a single pocket (35:1 profit) + bet 10 on 0 Bet on zero + bet 10 on 00 Double zero tables only + bet 10 on red Red or black (1:1) + bet 10 on odd Odd or even (1:1) + bet 10 on low Low 1-18 or high 19-36 (1:1) + bet 10 on dozen1 dozen1=1-12, dozen2=13-24, dozen3=25-36 (2:1) + bet 10 on column1 column1=1,4,...; column2=2,5,...; + column3=3,6,... (2:1) + bet / rebet Repeat your previous wager + bet min / bet max Change amount, retaining the previous target + sitout / stop Sit out or leave (queued if a wager is committed) + watch roulette Watch table graphics without joining + + One wager per player per spin. Split, street, corner, and multiple + simultaneous bets are not supported yet. Zero and double zero lose + all outside bets. Chips are spent first; winnings return as credits. + Displayed returns include your stake; odds above are profit odds. + + Betting opens with the first wager and runs for the full timer, even + alone. No bets are accepted while the wheel spins. The result shows + the winning pocket and its real physical neighbors on that wheel. diff --git a/data/objects/casino_test_portal.yaml b/data/objects/casino_test_portal.yaml new file mode 100644 index 0000000..cf1b6cb --- /dev/null +++ b/data/objects/casino_test_portal.yaml @@ -0,0 +1,14 @@ +name: casino test portal +aliases: [casino, test portal] +inroom_description: A casino test portal offers 1000 chips and a direct trip to Domita. +description: + - text: 'TESTING ONLY: use casino to receive 1000 chips and teleport to the House of Icarus vestibule in Domita. Repeatable; remove this object before launch.' +on_use: + - steps: + - drop_table: + - item_id: chips + quantity: 1000 + weight: 1 + - teleport: 6100 + messages: + - 'Testing shortcut: welcome to the Domita casino! Head north to explore the gaming floors.' diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index 40c69ed..63ee5bb 100644 --- a/data/rooms/casino/6100.yaml +++ b/data/rooms/casino/6100.yaml @@ -12,5 +12,6 @@ objects: - name: wayboard description: 'Grand stair: ground gaming floor above the entrance podium. West: five blackjack salons, three baccarat salons. East: five slot rooms. Three upper galleries reserve fifteen future game halls. + Roulette salons: north, up, north for single zero; continue north for double zero. All floors are open without a wager.' inroom_description: A wayboard stands where travelers can read it. diff --git a/data/rooms/casino/6102.yaml b/data/rooms/casino/6102.yaml index dd3f94e..264f3e8 100644 --- a/data/rooms/casino/6102.yaml +++ b/data/rooms/casino/6102.yaml @@ -3,6 +3,8 @@ description: The entrance stair rises onto a broad podium, the casino’s ground divides the floor into card salons west and machine galleries east. Higher stairs lead to future gaming floors. exits: + north: + room: 6182 down: room: 6101 south: diff --git a/data/rooms/casino/6182.yaml b/data/rooms/casino/6182.yaml new file mode 100644 index 0000000..c6bdcf5 --- /dev/null +++ b/data/rooms/casino/6182.yaml @@ -0,0 +1,16 @@ +name: House of Icarus — Single Zero Roulette Salon +description: A polished European wheel turns beside green felt marked with a single zero. The dealer invites visitors to play roulette. +exits: + south: + room: 6102 + north: + room: 6183 +casino_tables: + - id: roulette_single + game: roulette + betting_ticks: 15 + min_bet: 1 + max_bet: 10000 + roulette: + double_zero: false + spin_ticks: 4 diff --git a/data/rooms/casino/6183.yaml b/data/rooms/casino/6183.yaml new file mode 100644 index 0000000..0b00f36 --- /dev/null +++ b/data/rooms/casino/6183.yaml @@ -0,0 +1,14 @@ +name: House of Icarus — Double Zero Roulette Salon +description: An American roulette wheel gleams under brass lamps. Its green zero and double-zero pockets face a neatly marked betting layout. +exits: + south: + room: 6182 +casino_tables: + - id: roulette_double + game: roulette + betting_ticks: 15 + min_bet: 1 + max_bet: 10000 + roulette: + double_zero: true + spin_ticks: 4 diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml index 7843ad5..482618e 100644 --- a/data/rooms/intro/1001.yaml +++ b/data/rooms/intro/1001.yaml @@ -25,6 +25,7 @@ mobs: - id: flight_attendant name: Inside Transport Shuttle objects: + - id: casino_test_portal - description: - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {command}north{/}, {command}south{/}, {command}east{/}, {command}west{/}, {command}up{/}, {command}down{/}\n{0B bold}Look:{/} {command}look{/}, {command}look <item/object/mob>{/}, and {command}map{/}\n{0B bold}Items:{/} {command}inv{/} to see your 28-slot inventory\n {command}get <item>{/}, {command}drop <item>{/}, {command}wear <item>{/}, {command}remove <item>{/}\n {command}use <item> on <item/object>{/}\n {command}eq{/} for equiped gear\n{0B bold}Comms:{/} {command}talk <mob>{/} for NPC chat\n {command}say <msg>{/} and {command}global <msg>{/} for human chat\n{0B bold}Character:{/} {command}score{/} and {command}skills{/} for stats. \n{0B bold}Combat{/} {command}attack <mob>{/}\n{0B bold}Skills{/} Many skills have shortcuts like {command}mine{/}, {command}fish{/}, or {command}chop{/}\n{0B bold}Options{/} {command}options{/} for a list of account options\n {command}options <opt> <value>{/} to change an option\n\nType {command}what{/} for a list of possible common actions\n\nMake sure to check the {command}help{/} pages, especially {command}help newplayer{/}!" hidden: true 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) + } + } +} |
