aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/big_six.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 07:11:58 -0400
committerhistoria <[not public]>2026-10-03 07:11:58 -0400
commit0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc (patch)
treeac0af51cc2513b1ddd8a280f360af820d1f097af /internal/casino/big_six.go
parent6232d41e5639f9c38e47b2bb65266e343ca26e8f (diff)
downloadthehouseoficarus-0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc.tar.gz
feat: added big 6 wheel to the casino
Diffstat (limited to 'internal/casino/big_six.go')
-rw-r--r--internal/casino/big_six.go530
1 files changed, 530 insertions, 0 deletions
diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go
new file mode 100644
index 0000000..6d16bbc
--- /dev/null
+++ b/internal/casino/big_six.go
@@ -0,0 +1,530 @@
+package casino
+
+import (
+ "fmt"
+ "math/big"
+ "math/rand"
+ "strings"
+)
+
+// BigSixSpot describes a physical symbol and its profit odds. The dollar
+// labels follow Draft Kings; counts and odds are adjusted for this house.
+// Reference: https://wizardofodds.com/games/big-six/ (Draft Kings Rules).
+type BigSixSpot struct {
+ Label string
+ Count, Pays int
+}
+
+func BigSixSpots() []BigSixSpot {
+ return []BigSixSpot{
+ {"1", 24, 1}, {"2", 12, 3}, {"5", 6, 7}, {"10", 4, 11}, {"20", 2, 23},
+ {"SWORD", 1, 47}, {"SHIELD", 1, 47}, {"DOUBLE", 1, 0},
+ }
+}
+
+// Each ordinary symbol has count*(profit+1)=48. With N=51 and D=1,
+// E = 48/51 + (2/51)*E = 48/49 = 97.95918367%, for EVERY target.
+// DOUBLE is not a bet: it doubles all live stakes and spins again. No rounding.
+func bigSixHouseEdge(_ TableConfig, bet string) HouseEdge {
+ if bigSixPayout(bet) > 0 {
+ return edge(1.0 / 49)
+ }
+ return unknownEdge("unrecognized Big 6 target")
+}
+func bigSixTarget(s string) string {
+ return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(s)), "$")
+}
+func bigSixPayout(s string) int {
+ for _, spot := range BigSixSpots() {
+ if spot.Label == bigSixTarget(s) && spot.Pays > 0 {
+ return spot.Pays + 1
+ }
+ }
+ return 0
+}
+func bigSixRules(TableConfig) string {
+ return "Big 6: bet on $1, $2, $5, $10, $20, SWORD or SHIELD. Profit odds: 1, 3, 7, 11, 23, 47, 47 to 1. DOUBLE doubles every live bet and respins without a cap. RTP 48/49 (97.959%). Alone, place any number of bets then spin; multiplayer betting lasts 25 ticks, even with no bets."
+}
+
+// A fixed, interleaved physical wheel, never shuffled during a round.
+var bigSixWheel = func() []string {
+ var wheel []string
+ for _, spot := range BigSixSpots() {
+ for i := 0; i < spot.Count; i++ {
+ wheel = append(wheel, spot.Label)
+ }
+ }
+ rand.New(rand.NewSource(601)).Shuffle(len(wheel), func(i, j int) { wheel[i], wheel[j] = wheel[j], wheel[i] })
+ return wheel
+}()
+
+type bigSixBet struct {
+ spot string
+ wager Wager
+}
+type bigSixPlayer struct {
+ Participant
+ bets []bigSixBet
+ lastSpot string
+ lastBet int
+}
+
+func (p *bigSixPlayer) participantPtr() *Participant { return &p.Participant }
+
+type bigSixFrame struct{ delay, move int }
+type bigSixTable struct {
+ tableCore[*bigSixPlayer]
+ rng *rand.Rand
+ frames []bigSixFrame
+ frame, wait, position, winningIndex int
+ // Arbitrary precision keeps DOUBLE chains uncapped and prevents wraparound.
+ multiplier big.Int
+ lastResult *BigSixSnapshot
+}
+type BigSixSnapshot struct {
+ Players []string
+ Phase string
+ Pockets []string
+ Multiplier string
+ BettingTicks int
+ MinBet, MaxBet int
+}
+
+func (*BigSixSnapshot) snapshot() {}
+func newBigSixTable(room int, cfg TableConfig, rng *rand.Rand) *bigSixTable {
+ t := &bigSixTable{tableCore: newTableCore[*bigSixPlayer](&tableBase{RoomID: room, Config: cfg.Normalize()}), rng: rng}
+ t.multiplier.SetInt64(1)
+ return t
+}
+func (t *bigSixTable) active() int {
+ n := 0
+ for _, p := range t.players.ordered() {
+ if p.Connected && !p.SittingOut {
+ n++
+ }
+ }
+ return n
+}
+func (t *bigSixTable) openRound() []Event {
+ if t.phase == phaseWaiting && t.active() > 1 {
+ t.phase = phaseBetting
+ t.window.open(25)
+ return []Event{t.event("Big 6 betting is open for 25 ticks.", true)}
+ }
+ return nil
+}
+func (t *bigSixTable) view() *BigSixSnapshot {
+ s := &BigSixSnapshot{Phase: string(t.phase), Multiplier: t.multiplier.String(), BettingTicks: t.window.timer.remaining(), MinBet: t.Config.MinBet, MaxBet: t.Config.MaxBet}
+ for _, p := range t.players.ordered() {
+ s.Players = append(s.Players, p.Name)
+ }
+ return s
+}
+func (t *bigSixTable) strip(result bool) *BigSixSnapshot {
+ s := t.view()
+ count := 11
+ if result {
+ s.Phase = "result"
+ count = 9
+ }
+ for i := 0; i < count; i++ {
+ s.Pockets = append(s.Pockets, bigSixWheel[(t.position+i-count/2+len(bigSixWheel))%len(bigSixWheel)])
+ }
+ return s
+}
+func (t *bigSixTable) board(_ string) Snapshot {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.view()
+}
+func (t *bigSixTable) wagerRows() []WagerRow {
+ if t.phase == phasePlaying {
+ return nil
+ }
+ var rows []WagerRow
+ for _, s := range BigSixSpots() {
+ if s.Pays > 0 {
+ rows = append(rows, WagerRow{Bet: s.Label, Payout: fmt.Sprintf("%d:1", s.Pays), Win: "Wheel stops on " + s.Label})
+ }
+ }
+ return rows
+}
+func (t *bigSixTable) wagers(_ string) []WagerRow {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.wagerRows()
+}
+func (t *bigSixTable) commands(name string) []MenuCommand {
+ var c []MenuCommand
+ if p := t.players.get(name); p != nil && t.phase != phasePlaying {
+ c = append(c, MenuCommand{Command: "bet N on BET", Desc: "Add a wager"})
+ if p.lastBet > 0 {
+ c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat your last wager"})
+ }
+ if p.HasBet {
+ c = append(c, MenuCommand{Command: "unbet", Desc: "Take back all wagers"})
+ }
+ if t.phase == phaseWaiting {
+ c = append(c, MenuCommand{Command: "spin", Desc: "Start the wheel"})
+ }
+ }
+ c = append(c, MenuCommand{Command: "table", Desc: "Show the felt"}, MenuCommand{Command: "stop", Desc: "Leave after your bets settle"})
+ return withTableHints(c, t.wagerRows())
+}
+func (t *bigSixTable) hints(name string) []MenuCommand {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.commands(name)
+}
+func (t *bigSixTable) menu(name string, board bool) Event {
+ e := t.betMenuEvent(name, t.commands(name))
+ if board {
+ e.Snapshot = t.view()
+ }
+ return e
+}
+func (t *bigSixTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.players.has(name) {
+ return []Event{t.menu(name, true)}
+ }
+ if t.players.count() >= t.Config.MaxPlayers {
+ return []Event{t.private(name, "The Big 6 table is full.")}
+ }
+ e := t.addSeatLocked(name, &bigSixPlayer{Participant: Participant{Name: name, Connected: true}})
+ events := append([]Event{e}, t.openRound()...)
+ return append(events, t.menu(name, true))
+}
+func (t *bigSixTable) 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 Big 6 wagers are committed until the round ends.")}
+ }
+ t.removeSeatLocked(name)
+ if t.players.count() == 0 {
+ t.reset()
+ }
+ return t.closeIfEmptyLocked([]Event{t.playerAction(name, name+" stands up.", "You stand up.")})
+}
+func (t *bigSixTable) 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 Big 6.")}
+ }
+ if t.phase == phasePlaying {
+ return []Event{t.private(name, "No more bets! The wheel is spinning.")}
+ }
+ if spot == "" {
+ spot = p.lastSpot
+ }
+ spot = bigSixTarget(spot)
+ if bigSixPayout(spot) == 0 {
+ return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD. DOUBLE is automatic, not a bet.")}
+ }
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet {
+ return []Event{t.private(name, "Big 6 bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}
+ }
+ // Keep the original aggregate representable for shared menus and refunds.
+ if amount > int(^uint(0)>>1)-p.Wager.Total {
+ return []Event{t.private(name, "Your total stake is too large.")}
+ }
+ wager, err := w.Wager(amount)
+ if err != nil {
+ return []Event{t.private(name, walletErrorMessage(err))}
+ }
+ p.bets = append(p.bets, bigSixBet{spot, wager})
+ p.HasBet = true
+ p.Wallet = w
+ p.Wager.Total += amount
+ p.lastSpot, p.lastBet, p.SittingOut = 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))}
+ return append(events, t.openRound()...)
+}
+
+// Motion is planned backwards from an independently sampled uniform outcome.
+// Cosmetic randomness never consumes the table outcome RNG. Steps never grow
+// and waits never shrink, so the wheel only ever slows.
+//
+// The final approach ticks one space at a time. The wheel caps its wait so
+// it never crawls, but each click has a 50% chance of one more, so it can
+// tease forever. Half the time the very last click does not advance at all:
+// it re-prints the winning space. Only that last click may take a long pause.
+func bigSixAnimation() []bigSixFrame {
+ var frames []bigSixFrame
+ // Opening blur: 3-6 prints of 4-8 spaces each, easing down. (Two steps
+ // shorter than before so the wheel reaches its final approach sooner.)
+ step := 4 + rand.Intn(5) // 4..8
+ for n := 3 + rand.Intn(4); n > 0; n-- {
+ frames = append(frames, bigSixFrame{1, step})
+ if step > 4 && rand.Intn(2) == 0 {
+ step--
+ }
+ }
+ // The wheel eases down: longer waits, shorter hops.
+ step = min(step, 2+rand.Intn(3)) // 2..4
+ for n := 1 + rand.Intn(2); n > 0; n-- {
+ frames = append(frames, bigSixFrame{2, step})
+ }
+ step = min(step, 1+rand.Intn(2)) // 1..2
+ frames = append(frames, bigSixFrame{3, step})
+ // Final approach: single-space clicks. The wait is capped at maxApproachWait
+ // so the wheel keeps moving; after at least three clicks it may stop, but
+ // each further click has a 50% chance to continue.
+ const maxApproachWait = 4
+ for clicks := 1; ; clicks++ {
+ frames = append(frames, bigSixFrame{maxApproachWait, 1})
+ if clicks >= 3 && rand.Intn(2) == 0 {
+ break
+ }
+ }
+ // Half the time the wheel makes one final stationary click, printing the
+ // winning space again instead of ticking over to the next one.
+ if rand.Intn(2) == 0 {
+ frames = append(frames, bigSixFrame{maxApproachWait, 0})
+ }
+ // Occasionally a long suspense pause before the final click.
+ if rand.Intn(3) == 0 {
+ frames[len(frames)-1].delay = 8 + rand.Intn(5)
+ }
+ return frames
+}
+func (t *bigSixTable) startSpin() Event {
+ t.phase = phasePlaying
+ t.window.close()
+ t.winningIndex = t.rng.Intn(len(bigSixWheel))
+ t.frames = bigSixAnimation()
+ t.frame = 0
+ distance := 0
+ for _, f := range t.frames {
+ distance += f.move
+ }
+ t.position = (t.winningIndex - distance%len(bigSixWheel) + len(bigSixWheel)) % len(bigSixWheel)
+ t.wait = t.frames[0].delay
+ return t.event("No more bets! The brass Big 6 wheel begins to turn...", true)
+}
+func (t *bigSixTable) tick() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.phase == phaseWaiting {
+ return t.openRound()
+ }
+ if t.phase == phaseBetting {
+ t.window.timer.tick()
+ if t.window.timer.expired() {
+ return []Event{t.startSpin()}
+ }
+ return nil
+ }
+ t.wait--
+ if t.wait > 0 {
+ return nil
+ }
+ t.position = (t.position + t.frames[t.frame].move) % len(bigSixWheel)
+ t.frame++
+ if t.frame == len(t.frames) {
+ return t.settle()
+ }
+ t.wait = t.frames[t.frame].delay
+ e := t.event("", true)
+ e.Snapshot = t.strip(false)
+ return []Event{e}
+}
+func (t *bigSixTable) settle() []Event {
+ spot := bigSixWheel[t.winningIndex]
+ t.position = t.winningIndex
+ e := t.event("The pointer stops on {casino_emphasis}"+spot+"{/}!", true)
+ e.Snapshot = t.strip(true)
+ t.lastResult = e.Snapshot.(*BigSixSnapshot)
+ events := []Event{e}
+ if spot == "DOUBLE" {
+ t.multiplier.Lsh(&t.multiplier, 1)
+ events = append(events, t.event("DOUBLE! Every live bet doubles; the same bets ride again. Stake multiplier: "+t.multiplier.String()+"x.", true))
+ return append(events, t.startSpin())
+ }
+ for _, p := range t.players.ordered() {
+ if !p.HasBet {
+ continue
+ }
+ total := new(big.Int)
+ var ids []string
+ var lines []SettlementLine
+ representable := true
+ for _, b := range p.bets {
+ ids = append(ids, b.wager.ID)
+ returned := new(big.Int)
+ result := "loss"
+ if b.spot == spot {
+ returned.Mul(big.NewInt(int64(b.wager.Total)), &t.multiplier)
+ returned.Mul(returned, big.NewInt(int64(bigSixPayout(spot))))
+ result = "win"
+ }
+ total.Add(total, returned)
+ if !returned.IsInt64() || returned.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 {
+ representable = false
+ }
+ odds := new(big.Int).Mul(&t.multiplier, big.NewInt(int64(bigSixPayout(b.spot))))
+ odds.Sub(odds, big.NewInt(1))
+ lines = append(lines, SettlementLine{Label: b.spot, Odds: odds.String() + ":1", Stake: b.wager.Total, Returned: int(returned.Int64()), Result: result})
+ }
+ if !total.IsInt64() || total.Cmp(big.NewInt(int64(int(^uint(0)>>1)))) > 0 {
+ representable = false
+ }
+ // One atomic save pays ALL bets and consumes exactly their ledger handles.
+ // Unrepresentable payouts stay pending for recovery, never wrap or clamp.
+ failed := !representable
+ if !failed {
+ failed = p.Wallet.PayCredits(int(total.Int64()), ids...) != nil
+ }
+ ev := t.playerAction(p.Name, p.Name+" settles their Big 6 wagers.", "")
+ if failed {
+ ev.PrivateMessage = payoutFailedMessage()
+ ev.Message = p.Name + "'s settlement is pending recovery."
+ } else {
+ ev.PrivateMessage = settlementText(lines)
+ ev.Settlement = lines
+ if t.multiplier.Cmp(big.NewInt(1)) > 0 {
+ ev.SettlementNote = "DOUBLE stake multiplier: " + t.multiplier.String() + "x; odds and net are relative to your original stake."
+ }
+ ev.Color = settlementTag(lines)
+ }
+ // The next-spin menu already shows the balance for seats that remain.
+ // Only a departing or disconnected seat needs it here, otherwise the
+ // wallet would print twice after a result.
+ ev.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
+ events = append(events, ev)
+ }
+ t.reset()
+ events = append(events, t.finishControlsLocked()...)
+ events = append(events, t.openRound()...)
+ for _, p := range t.players.ordered() {
+ if p.Connected && !p.SittingOut {
+ events = append(events, t.menu(p.Name, false))
+ }
+ }
+ return events
+}
+func (t *bigSixTable) reset() {
+ t.phase = phaseWaiting
+ t.window.close()
+ t.frames = nil
+ t.multiplier.SetInt64(1)
+ for _, p := range t.players.ordered() {
+ p.bets = nil
+ p.HasBet = false
+ p.Wager = Wager{}
+ }
+}
+func (t *bigSixTable) action(name, action string) []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 Big 6.")}
+ }
+ if action == "unbet" {
+ if t.phase == phasePlaying {
+ return []Event{t.private(name, "You cannot unbet after the wheel starts, including DOUBLE respins.")}
+ }
+ if !p.HasBet {
+ return []Event{t.private(name, "Nothing to take back.")}
+ }
+ var ids []string
+ for _, b := range p.bets {
+ ids = append(ids, b.wager.ID)
+ }
+ if err := p.Wallet.PayCredits(p.Wager.Total, ids...); err != nil {
+ return []Event{t.private(name, payoutFailedMessage())}
+ }
+ total := p.Wager.Total
+ p.bets = nil
+ p.HasBet = false
+ p.Wager = Wager{}
+ return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))}
+ }
+ if action == "spin" {
+ if t.phase == phasePlaying {
+ return []Event{t.private(name, "The wheel is already spinning.")}
+ }
+ if t.phase == phaseBetting {
+ return []Event{t.private(name, fmt.Sprintf("The shared betting round has %d ticks remaining.", t.window.timer.remaining()))}
+ }
+ if !p.HasBet {
+ return []Event{t.private(name, "Place your bets before spinning.")}
+ }
+ return []Event{t.startSpin()}
+ }
+ if events, ok := t.controlLocked(name, action); ok {
+ return events
+ }
+ return []Event{t.private(name, "Place bets, then spin. Use rules for Big 6 help.")}
+}
+func (t *bigSixTable) 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 *bigSixTable) status(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ events := []Event{t.menu(name, true)}
+ if t.phase != phasePlaying && t.lastResult != nil {
+ e := t.private(name, "Last stop:")
+ e.Snapshot = t.lastResult
+ events = append(events, e)
+ }
+ return events
+}
+func (t *bigSixTable) markDisconnected(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.players.get(name)
+ if p == nil {
+ return nil
+ }
+ // Once animation reveals positions, refunds would permit selective losses.
+ // Match the casino's disconnect-forfeit convention for exposed games.
+ var events []Event
+ if p.Wallet != nil {
+ var err error
+ if p.HasBet && t.phase == phasePlaying {
+ var ids []string
+ for _, b := range p.bets {
+ ids = append(ids, b.wager.ID)
+ }
+ err = p.Wallet.PayCredits(0, ids...)
+ } else {
+ _, _, err = p.Wallet.RefundOutstanding()
+ }
+ if err != nil {
+ events = append(events, t.private(name, payoutFailedMessage()))
+ }
+ }
+ t.removeSeatLocked(name)
+ if t.players.count() == 0 {
+ t.reset()
+ }
+ return t.closeIfEmptyLocked(append(events, t.event(name+" stands up.", true)))
+}
+func (t *bigSixTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ events := t.refundAllLocked()
+ t.reset()
+ return events
+}
+func (t *bigSixTable) busy(_ string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.phase == phasePlaying
+}