diff options
| author | historia <[not public]> | 2026-10-03 07:11:58 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-03 07:11:58 -0400 |
| commit | 0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc (patch) | |
| tree | ac0af51cc2513b1ddd8a280f360af820d1f097af /internal/casino/big_six.go | |
| parent | 6232d41e5639f9c38e47b2bb65266e343ca26e8f (diff) | |
| download | thehouseoficarus-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.go | 530 |
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 +} |
