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)), "$") } // bigSixResolveTarget maps a written target to a betting symbol. An exact // label always wins over a longer label that shares its prefix, so "1" is the // $1 symbol and never an ambiguous prefix of $10. Otherwise an unambiguous // prefix is accepted, so "SW", "SWO" and "SWORD" all name the sword and "SH" // names the shield. DOUBLE is not a betting target, so "DO" resolves to // nothing. It reports false when nothing or more than one symbol matches. func bigSixResolveTarget(s string) (string, bool) { t := bigSixTarget(s) if t == "" { return "", false } match := "" for _, spot := range BigSixSpots() { if spot.Pays <= 0 { continue } if spot.Label == t { return spot.Label, true } if strings.HasPrefix(spot.Label, t) { if match != "" { return "", false } match = spot.Label } } return match, match != "" } func bigSixPayout(s string) int { target, ok := bigSixResolveTarget(s) if !ok { return 0 } for _, spot := range BigSixSpots() { if spot.Label == target { 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 lastBets []rebetWager 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 len(p.lastBets) > 0 { c = append(c, MenuCommand{Command: "bet / rebet", Desc: "Repeat all your last wagers"}) } 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() events, _ := t.betLocked(name, spot, amount, w) return events } // betLocked is the locked implementation of bet, shared with the multi-wager // replay so a rebet holds the table lock across every stake. It reports false // when no wager was placed. func (t *bigSixTable) betLocked(name, spot string, amount int, w Wallet) ([]Event, bool) { p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't playing Big 6.")}, false } if t.phase == phasePlaying { return []Event{t.private(name, "No more bets! The wheel is spinning.")}, false } if spot == "" { spot = p.lastSpot } resolved, ok := bigSixResolveTarget(spot) if !ok { return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")}, false } spot = resolved 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)+".")}, false } // 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.")}, false } wager, err := w.Wager(amount) if err != nil { return []Event{t.private(name, walletErrorMessage(err))}, false } 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()...), true } // rebetAll repeats every remembered wager from the previous round in one // command, so a bare bet/rebet restores a whole spread. func (t *bigSixTable) rebetAll(name string, 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 len(p.lastBets) == 0 { return []Event{t.private(name, "Bet on 1, 2, 5, 10, 20, SWORD or SHIELD — an unambiguous prefix is fine. DOUBLE is automatic, not a bet.")} } var events []Event for _, b := range p.lastBets { ev, ok := t.betLocked(name, b.spot, b.amount, w) events = append(events, ev...) if !ok { // A rejected stake ends the replay rather than partially betting. break } } return events } // 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 = p.SettlementBalanceNeeded(failed) 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.lastBets = p.lastBets[:0] for _, b := range p.bets { p.lastBets = append(p.lastBets, rebetWager{spot: b.spot, amount: b.wager.Total}) } 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 }