diff options
| author | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-04 17:44:27 -0400 |
| commit | 93dcb67ffee05ebf1de0c9a78a8b906eaf161007 (patch) | |
| tree | 88cdd3d732f11eee616698a9ca32f699d2b41d77 /internal/casino/cash_hunt.go | |
| parent | 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (diff) | |
| download | thehouseoficarus-93dcb67ffee05ebf1de0c9a78a8b906eaf161007.tar.gz | |
feat: new casino game: cash hunt, fixed verb issue with pai gow
Diffstat (limited to 'internal/casino/cash_hunt.go')
| -rw-r--r-- | internal/casino/cash_hunt.go | 342 |
1 files changed, 342 insertions, 0 deletions
diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go new file mode 100644 index 0000000..7c9752f --- /dev/null +++ b/internal/casino/cash_hunt.go @@ -0,0 +1,342 @@ +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 +} |
