package casino import ( "fmt" "math/rand" "strconv" "strings" "sync" ) const CashHuntSquares = 100 const cashHuntWeightTotal = 100_000_000 const cashHuntMaxMultiplier = 50_000 var cashHuntRisks = [...]string{"easy", "medium", "hard"} var cashHuntMultipliers = [...]int{0, 1, 2, 5, 10, 20, 100, 200, 500, 1000, 10000, 50000} // Each row sums to 100 million and its multiplier-weighted sum is exactly // 98 million. All risks return 98%; only variance changes. Integer tickets // make even the rarest prizes reachable without floating-point thresholds. var cashHuntWeights = [3][12]int{ {50846940, 30000000, 15000000, 3000000, 800000, 300000, 40000, 10000, 2000, 1000, 50, 10}, {71345640, 15000000, 10000000, 2000000, 1000000, 500000, 100000, 40000, 10000, 4000, 300, 60}, {88068640, 5000000, 5000000, 1000000, 500000, 250000, 100000, 50000, 20000, 10000, 1000, 360}, } func cashHuntPrize(risk, ticket int) int { for i, weight := range cashHuntWeights[risk] { if ticket < weight { return cashHuntMultipliers[i] } ticket -= weight } panic("invalid Cash Hunt ticket") } func validateCashHunt(c TableConfig) error { if c.MaxPlayers > 1 { return fmt.Errorf("Cash Hunt has one seat") } if c.MaxBet > MaxCredits/cashHuntMaxMultiplier { return fmt.Errorf("Cash Hunt max_bet must not exceed %d", MaxCredits/cashHuntMaxMultiplier) } return nil } func cashHuntRules(TableConfig) string { return "Cash Hunt: 100 squares; easy / medium / hard; 98% RTP at every difficulty.\nBet first to generate and reveal the prizes. Their positions are then hidden and uniformly shuffled before you choose. Type a number from 1 to 100 (or pick N). No prizes are generated after your choice.\nMultipliers include your stake. 0x loses, 1x returns your stake. Rare prizes reach 50000x; large prizes are not guaranteed on any board.\nYou cannot cancel or change difficulty after betting. On disconnect, an unpicked board settles square 1. Shutdown refunds unpicked boards. Failed payouts remain pending; settle retries the same prize." } type CashHuntPrizeOdds struct{ Multiplier, Weight int } type CashHuntSnapshot struct { Phase string // idle, preview, hidden, result, paytable Risk string Squares [CashHuntSquares]int // never populated while hidden Selected int // one-based; zero means not chosen Odds []CashHuntPrizeOdds } func (*CashHuntSnapshot) snapshot() {} type cashHuntTable struct { *tableBase mu sync.Mutex rng *rand.Rand name string risk, betSize int active bool squares [CashHuntSquares]int selected int wallet Wallet stake Wager } func newCashHuntTable(room int, c TableConfig, rng *rand.Rand) tableGame { return &cashHuntTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng} } func (t *cashHuntTable) base() *tableBase { return t.tableBase } func (t *cashHuntTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.name == "" } func (t *cashHuntTable) hasParticipant(n string) bool { t.mu.Lock() defer t.mu.Unlock() return t.name != "" && t.name == n } func (t *cashHuntTable) overview() string { return "Cash Hunt / 100 hidden prizes" } func (t *cashHuntTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() if t.name == "" { return nil } return []string{t.name} } func (t *cashHuntTable) state() string { t.mu.Lock() defer t.mu.Unlock() if t.active { if t.selected != 0 { return "payout pending" } return "choosing a square" } return "waiting" } func (t *cashHuntTable) wagers(string) []WagerRow { return nil } func (t *cashHuntTable) tick() []Event { return nil } func (t *cashHuntTable) hintsLocked() []MenuCommand { if t.active { if t.selected != 0 { return []MenuCommand{{Command: "settle", Desc: "Retry the pending payout"}} } return []MenuCommand{{Command: "pick N", Desc: "Choose square 1-100; a bare number also works"}, {Command: "rules", Desc: "Cash Hunt rules"}} } return []MenuCommand{{Command: "bet N", Desc: "Reveal 100 prizes and shuffle their positions"}, {Command: "difficulty easy|medium|hard", Desc: "Choose variance; all return 98%"}, {Command: "paytable", Desc: "Prize probabilities"}, {Command: "rules", Desc: "Cash Hunt rules"}, {Command: "stop", Desc: "Leave Cash Hunt"}} } func (t *cashHuntTable) hints(n string) []MenuCommand { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } return t.hintsLocked() } func (t *cashHuntTable) snapLocked(phase string) *CashHuntSnapshot { s := &CashHuntSnapshot{Phase: phase, Risk: cashHuntRisks[t.risk], Selected: t.selected} if phase == "preview" || phase == "result" { s.Squares = t.squares } if phase == "paytable" { for i, m := range cashHuntMultipliers { s.Odds = append(s.Odds, CashHuntPrizeOdds{m, cashHuntWeights[t.risk][i]}) } } return s } func (t *cashHuntTable) phaseLocked() string { if t.selected != 0 { return "result" } if t.active { return "hidden" } return "idle" } func (t *cashHuntTable) board(string) Snapshot { t.mu.Lock() defer t.mu.Unlock() return t.snapLocked(t.phaseLocked()) } func (t *cashHuntTable) statusLocked(n string) []Event { e := t.private(n, "") e.Snapshot = t.snapLocked(t.phaseLocked()) e.Color = "casino_menu" if n == t.name { e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} } return []Event{e} } func (t *cashHuntTable) status(n string) []Event { t.mu.Lock() defer t.mu.Unlock() return t.statusLocked(n) } func (t *cashHuntTable) join(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if t.name != "" { return []Event{t.private(n, "Cash Hunt is occupied.")} } t.name, t.risk = n, 0 e := t.private(n, "") e.Type, e.Player = EventJoined, n return append([]Event{e}, t.statusLocked(n)...) } func (t *cashHuntTable) clearLocked() { t.name, t.active, t.selected, t.betSize = "", false, 0, 0 t.wallet, t.stake, t.squares = nil, Wager{}, [CashHuntSquares]int{} } func (t *cashHuntTable) leave(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active { return []Event{t.private(n, "Finish your hunt before leaving.")} } t.clearLocked() e := t.playerAction(n, n+" leaves Cash Hunt.", "You leave Cash Hunt.") e.Type, e.Player = EventLeft, n return []Event{e} } func (t *cashHuntTable) rebet(n string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if n == t.name { return "", t.betSize } return "", 0 } func (t *cashHuntTable) bet(n, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } if t.active { return []Event{t.private(n, "Your prizes are already sealed. Finish this hunt first.")} } if spot != "" && spot != "main" { return []Event{t.private(n, "Use bet N, then choose a square.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > MaxCredits/cashHuntMaxMultiplier { return []Event{t.private(n, "Bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} } if w == nil { return []Event{t.private(n, "No wallet is available.")} } stake, err := w.Wager(amount) if err != nil { return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))} } t.wallet, t.stake, t.betSize, t.active, t.selected = w, stake, amount, true, 0 for i := range t.squares { t.squares[i] = cashHuntPrize(t.risk, t.rng.Intn(cashHuntWeightTotal)) } preview := t.private(n, fmt.Sprintf("Stake: %d credits. Here are your 100 prizes.", amount)) preview.Snapshot = t.snapLocked("preview") // value copy BEFORE the shuffle t.rng.Shuffle(len(t.squares), func(i, j int) { t.squares[i], t.squares[j] = t.squares[j], t.squares[i] }) sealed := t.private(n, "") sealed.Snapshot = t.snapLocked("hidden") return []Event{preview, sealed} } func (t *cashHuntTable) action(n, action string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } f := strings.Fields(strings.ToLower(action)) if len(f) == 1 && f[0] == "paytable" { e := t.private(n, "") e.Snapshot = t.snapLocked("paytable") return []Event{e} } if len(f) == 2 && f[0] == "difficulty" { if t.active { return []Event{t.private(n, "Difficulty is locked until this hunt finishes.")} } for risk, name := range cashHuntRisks { if f[1] == name { t.risk = risk t.selected = 0 return t.statusLocked(n) } } return []Event{t.private(n, "Use difficulty easy|medium|hard.")} } if len(f) == 1 && f[0] == "settle" && t.active && t.selected != 0 { return t.settleLocked() } if len(f) == 2 && f[0] == "pick" { f = f[1:] } if len(f) != 1 { return []Event{t.private(n, "Choose a square with pick N or a number from 1 to 100.")} } square, err := strconv.Atoi(f[0]) if err != nil || square < 1 || square > CashHuntSquares { return []Event{t.private(n, "Choose a square from 1 to 100.")} } if !t.active { return []Event{t.private(n, "Use bet N to start a new hunt.")} } if t.selected != 0 { return []Event{t.private(n, "Your choice is locked. Use settle to retry its payout.")} } t.selected = square return t.settleLocked() } // Selection and settlement deliberately contain no RNG calls. A failed bank // save leaves the SAME chosen square and stake locked for an idempotent retry. func (t *cashHuntTable) settleLocked() []Event { multiplier := t.squares[t.selected-1] paid := t.betSize * multiplier reveal := t.private(t.name, fmt.Sprintf("Square %03d: %dx.", t.selected, multiplier)) reveal.Snapshot = t.snapLocked("result") if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil { return []Event{reveal, t.private(t.name, payoutFailedMessage()+" Use settle to retry this same prize.")} } t.active, t.stake = false, Wager{} outcome := "loss" if multiplier == 1 { outcome = "push" } else if multiplier > 1 { outcome = "win" } receipt := t.private(t.name, "") receipt.Settlement = []SettlementLine{{Label: fmt.Sprintf("Cash Hunt #%03d", t.selected), Odds: "Varies", Stake: t.betSize, Returned: paid, Result: outcome}} receipt.Color = "casino_payout" if multiplier >= 100 { receipt.Color = "casino_big_win" } return []Event{reveal, receipt, t.betMenuEvent(t.name, t.hintsLocked())} } func (t *cashHuntTable) markDisconnected(n string) []Event { t.mu.Lock() defer t.mu.Unlock() if n != t.name { return nil } var events []Event if t.active { if t.selected == 0 { t.selected = 1 } events = t.settleLocked() } t.clearLocked() e := t.event(n+" leaves Cash Hunt.", true) e.Type, e.Player = EventLeft, n return append(events, e) } func (t *cashHuntTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() var events []Event if t.active && t.selected != 0 { events = t.settleLocked() } else if t.active { chips, credits, err := t.wallet.RefundOutstanding() message := fmt.Sprintf("Cash Hunt closes; %d credits/chips refunded.", chips+credits) if err != nil { message = "Cash Hunt refund could not be persisted; your stake remains pending for recovery." } events = []Event{t.private(t.name, message)} } t.clearLocked() return events }