From 93dcb67ffee05ebf1de0c9a78a8b906eaf161007 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 4 Oct 2026 17:44:27 -0400 Subject: feat: new casino game: cash hunt, fixed verb issue with pai gow --- internal/casino/cash_hunt.go | 342 ++++++++++++++++++++++++++++++++++++++ internal/casino/cash_hunt_test.go | 265 +++++++++++++++++++++++++++++ internal/casino/core.go | 3 + internal/casino/plinko.go | 173 +++++++++++++++---- internal/casino/plinko_test.go | 116 +++++++++++-- internal/casino/registry.go | 9 + internal/casino/types.go | 1 + 7 files changed, 862 insertions(+), 47 deletions(-) create mode 100644 internal/casino/cash_hunt.go create mode 100644 internal/casino/cash_hunt_test.go (limited to 'internal/casino') 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 +} diff --git a/internal/casino/cash_hunt_test.go b/internal/casino/cash_hunt_test.go new file mode 100644 index 0000000..62cbb06 --- /dev/null +++ b/internal/casino/cash_hunt_test.go @@ -0,0 +1,265 @@ +package casino + +import ( + "math" + "math/rand" + "sort" + "strconv" + "testing" +) + +func newTestCashHunt() *cashHuntTable { + return newTable(1, TableConfig{ID: "cash_hunt", Game: GameCashHunt}, rand.New(rand.NewSource(42))).game.(*cashHuntTable) +} + +type cashHuntNoRandom struct{} + +func (cashHuntNoRandom) Int63() int64 { panic("Cash Hunt used RNG after the board was sealed") } +func (cashHuntNoRandom) Seed(int64) {} + +func TestCashHuntExactRTPAndVariance(t *testing.T) { + lastVariance := 0.0 + for risk, weights := range cashHuntWeights { + total, returned, boundary := 0, int64(0), 0 + second := 0.0 + for i, weight := range weights { + multiplier := cashHuntMultipliers[i] + if weight <= 0 { + t.Fatal("every advertised prize must be reachable") + } + for _, ticket := range []int{boundary, boundary + weight - 1} { + if got := cashHuntPrize(risk, ticket); got != multiplier { + t.Fatalf("risk %d ticket %d: %dx, want %dx", risk, ticket, got, multiplier) + } + } + boundary += weight + total += weight + returned += int64(weight) * int64(multiplier) + second += float64(weight) * float64(multiplier) * float64(multiplier) / cashHuntWeightTotal + } + if total != cashHuntWeightTotal || returned != 98_000_000 { + t.Fatalf("risk %d: tickets %d, weighted return %d", risk, total, returned) + } + variance := second - .98*.98 + if variance <= lastVariance { + t.Fatal("difficulty must increase variance") + } + lastVariance = variance + } + c := TableConfig{ID: "hunt", Game: GameCashHunt} + if got := c.HouseEdge("main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-.02) > 1e-12 { + t.Fatalf("wrong reported edge: %+v", got) + } +} + +func TestCashHuntEveryChoiceUsesSealedBoard(t *testing.T) { + for pick := 1; pick <= CashHuntSquares; pick++ { + table := newTestCashHunt() + table.join("alice") + wallet := &testWallet{credits: 100} + events := table.bet("alice", "", 10, wallet) + preview := events[0].Snapshot.(*CashHuntSnapshot) + hidden := events[1].Snapshot.(*CashHuntSnapshot) + if hidden.Phase != "hidden" || hidden.Squares != [CashHuntSquares]int{} { + t.Fatal("hidden snapshot leaked prizes") + } + if preview.Squares == table.squares { + t.Fatal("seeded board was not shuffled") + } + before, after := preview.Squares, table.squares + sort.Ints(before[:]) + sort.Ints(after[:]) + if before != after { + t.Fatal("shuffle changed prize inventory") + } + sealed := table.squares + // Neither look/status nor an invalid input may reveal or regenerate it. + table.rng = rand.New(cashHuntNoRandom{}) + if table.board("spectator").(*CashHuntSnapshot).Squares != [CashHuntSquares]int{} { + t.Fatal("spectator can see hidden board") + } + table.status("alice") + for _, input := range []string{"0", "201", "-1", "pick 1 2", "difficulty hard", "nope"} { + table.action("alice", input) + } + table.bet("alice", "", 10, wallet) + table.leave("alice") + if !table.active || table.risk != 0 || table.squares != sealed || len(wallet.open) != 1 { + t.Fatal("locked board changed") + } + result := table.action("alice", strconv.Itoa(pick)) + if wallet.paid != 10*sealed[pick-1] || len(wallet.open) != 0 || table.active { + t.Fatal("selected square was not paid exactly") + } + snap := result[0].Snapshot.(*CashHuntSnapshot) + if snap.Squares != sealed || snap.Selected != pick || preview.Squares == sealed { + t.Fatal("reveal or preview mutated") + } + if len(result[1].Settlement) != 1 { + t.Fatal("receipt missing") + } + paid := wallet.paid + table.action("alice", strconv.Itoa(pick)) + table.action("alice", "settle") + table.tick() + table.refundAll() + if wallet.paid != paid { + t.Fatal("duplicate payout/refund") + } + } +} + +func TestCashHuntAllPrizePayouts(t *testing.T) { + for _, multiplier := range cashHuntMultipliers { + table := newTestCashHunt() + table.join("alice") + wallet := &testWallet{credits: 100} + table.bet("alice", "", 10, wallet) + table.squares[99] = multiplier + table.rng = rand.New(cashHuntNoRandom{}) + events := table.action("alice", "pick 100") + receipt := events[1].Settlement[0] + if wallet.paid != multiplier*10 || receipt.Returned != multiplier*10 || len(wallet.open) != 0 { + t.Fatalf("wrong payout for %dx", multiplier) + } + if receipt.Result != returnResult(10, multiplier*10) { + t.Fatal("wrong win/push/loss") + } + } +} + +func TestCashHuntPayoutFailureLocksChoice(t *testing.T) { + table := newTestCashHunt() + table.join("alice") + wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + table.bet("alice", "", 10, wallet) + table.squares[6] = 50000 + table.rng = rand.New(cashHuntNoRandom{}) + events := table.action("alice", "7") + for _, e := range events { + if len(e.Settlement) != 0 { + t.Fatal("failed payment claimed a receipt") + } + } + table.action("alice", "8") + table.leave("alice") + table.bet("alice", "", 1, wallet) + if !table.active || table.selected != 7 || len(wallet.open) != 1 || wallet.paid != 0 { + t.Fatal("failed payout lost its chosen prize or stake") + } + table.wallet = &wallet.testWallet // persistence recovered, same pending stake + table.action("alice", "settle") + if table.active || wallet.paid != 500000 || len(wallet.open) != 0 { + t.Fatal("retry did not pay the original prize") + } + table.action("alice", "settle") + if wallet.paid != 500000 { + t.Fatal("retry paid twice") + } +} + +func TestCashHuntDisconnectAndShutdown(t *testing.T) { + for _, disconnect := range []bool{false, true} { + table := newTestCashHunt() + table.join("alice") + wallet := &testWallet{chips: 3, credits: 100} + table.bet("alice", "", 10, wallet) + sealed := table.squares + table.rng = rand.New(cashHuntNoRandom{}) + if disconnect { + table.markDisconnected("alice") + if wallet.paid != 10*sealed[0] { + t.Fatal("disconnect did not settle committed square 1") + } + } else { + table.refundAll() + if wallet.chips != 3 || wallet.credits+wallet.paid != 100 { + t.Fatal("shutdown failed to refund unpicked board") + } + } + if !table.idle() || len(wallet.open) != 0 { + t.Fatal("seat or stake leaked") + } + paid := wallet.paid + table.refundAll() + if wallet.paid != paid { + t.Fatal("duplicate closure payment") + } + } + // A failed bank save on disconnect remains in the durable pending ledger. + table := newTestCashHunt() + table.join("alice") + wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + table.bet("alice", "", 10, wallet) + table.markDisconnected("alice") + if !table.idle() || len(wallet.open) != 1 { + t.Fatal("failed disconnect settlement consumed the stake") + } +} + +func TestCashHuntChosenPrizeSurvivesClosure(t *testing.T) { + for _, disconnect := range []bool{false, true} { + table := newTestCashHunt() + table.join("alice") + wallet := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + table.bet("alice", "", 10, wallet) + table.squares[0], table.squares[6] = 0, 200 + table.action("alice", "7") // selected, but banking failed + table.wallet = &wallet.testWallet + table.rng = rand.New(cashHuntNoRandom{}) + if disconnect { + table.markDisconnected("alice") + } else { + table.refundAll() + } + if !table.idle() || len(wallet.open) != 0 || wallet.paid != 2000 { + t.Fatal("closure must settle the chosen prize, not refund or substitute square 1") + } + } +} + +func TestCashHuntConfigAndBetGuards(t *testing.T) { + for _, c := range []TableConfig{ + {ID: "hunt", Game: GameCashHunt, MaxPlayers: 2}, + {ID: "hunt", Game: GameCashHunt, MaxBet: MaxCredits/cashHuntMaxMultiplier + 1}, + {ID: "hunt", Game: GameCashHunt, Plinko: PlinkoConfig{RTP: 98}}, + } { + if c.Validate() == nil { + t.Fatalf("invalid config accepted: %+v", c) + } + } + c := TableConfig{ID: "hunt", Game: GameCashHunt, MaxBet: MaxCredits / cashHuntMaxMultiplier} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + table := newTestCashHunt() + table.join("alice") + if table.join("bob")[0].Type == EventJoined { + t.Fatal("second player joined") + } + wallet := &testWallet{credits: 100} + for _, amount := range []int{-1, 0, MaxCredits/cashHuntMaxMultiplier + 1} { + table.bet("alice", "", amount, wallet) + } + table.bet("alice", "square 7", 1, wallet) + table.bet("bob", "", 1, wallet) + table.bet("alice", "", 1, nil) + table.bet("alice", "", 1, &testWallet{}) + if table.active || len(wallet.open) != 0 { + t.Fatal("invalid bet changed state") + } + for risk, name := range cashHuntRisks { + table.action("alice", "difficulty "+name) + if table.risk != risk { + t.Fatal("difficulty did not change") + } + snapshot := table.action("alice", "paytable")[0].Snapshot.(*CashHuntSnapshot) + if len(snapshot.Odds) != 12 || snapshot.Odds[11].Multiplier != 50000 { + t.Fatal("incomplete paytable") + } + } + table.bet("alice", "", 1, wallet) + if spot, amount := table.rebet("alice"); spot != "" || amount != 1 { + t.Fatal("rebet not remembered") + } +} diff --git a/internal/casino/core.go b/internal/casino/core.go index 3956750..5631c0f 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -187,6 +187,9 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error // gameTitle renders a configured game name as a message heading. func gameTitle(game GameName) string { + if game == GameCashHunt { + return "Cash Hunt" + } if game == GameBigSix { return "Big 6" } diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go index d73a0a4..412b72c 100644 --- a/internal/casino/plinko.go +++ b/internal/casino/plinko.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "math/rand" + "sort" "strconv" "strings" "sync" @@ -11,9 +12,7 @@ import ( const plinkoRows = 16 -const plinkoFirstRow = 0 - -// RTP is the base percentage before pocket multipliers round up to tenths. +// RTP is the target return percentage for the one-decimal paytables. // Fixed expert edge prizes alone return 30.517578125%. type PlinkoConfig struct { RTP float64 `yaml:"rtp,omitempty"` @@ -41,10 +40,23 @@ func validatePlinko(c TableConfig) error { var plinkoRisks = []string{"easy", "medium", "hard", "expert"} +// Balance in difficulty order so higher risk never reduces actual RTP. +// Compare exact path-weighted tenths, avoiding floating-point ordering ties. +func plinkoPockets(risk int, rtp float64) [17]float64 { + var pockets [17]float64 + minimum := 0 + for difficulty := 0; difficulty <= risk; difficulty++ { + pockets, minimum = plinkoBalancedPockets(difficulty, rtp, minimum) + } + return pockets +} + // Every path has probability 2^-16. Normalize interior weights against the // binomial distribution, keeping both edge jackpots fixed at every RTP. -// Round the played multipliers UP to tenths, intentionally raising actual RTP. -func plinkoPockets(risk int, rtp float64) [17]float64 { +// Choose floor/ceil tenths jointly, rather than rounding every pocket upward: +// all 256 symmetric rounding combinations are cheap to check, and the closest +// monotone paytable tracks the target without inflating the actual return. +func plinkoBalancedPockets(risk int, rtp float64, minimum int) ([17]float64, int) { var out [17]float64 outer := []float64{16, 100, 1000, 10000}[risk] powers := []float64{2, 4, 7, 12} @@ -55,11 +67,45 @@ func plinkoPockets(risk int, rtp float64) [17]float64 { out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk]) sum += choose * out[k] / 65536 } - scale := (rtp/100 - 2*outer/65536) / sum - for k := 1; k < 16; k++ { - out[k] = math.Ceil(out[k]*scale*10) / 10 + // If rounding raised a lower risk's return, center this risk's candidates + // on that return rather than searching below its required floor. + scale := (max(rtp/100, float64(minimum)/655360) - 2*outer/65536) / sum + var low, high [9]int + for k := 1; k <= 8; k++ { + v := out[k] * scale * 10 + low[k], high[k] = int(math.Floor(v)), int(math.Ceil(v)) + } + // Number of paths to each pocket, mirrored about the center. + paths := [9]int{1, 16, 120, 560, 1820, 4368, 8008, 11440, 12870} + target := rtp / 100 * 655360 // expected return in path-weighted tenths + bestError := math.Inf(1) + var best [17]float64 + bestWeighted := 0 + for mask := 0; mask < 256; mask++ { + candidate := [17]float64{0: outer, 16: outer} + weighted := int(outer) * 20 + valid := true + for k := 1; k <= 8; k++ { + v := low[k] + if mask&(1<<(k-1)) != 0 { + v = high[k] + } + candidate[k], candidate[16-k] = float64(v)/10, float64(v)/10 + if candidate[k] > candidate[k-1] { + valid = false + break + } + copies := 2 + if k == 8 { + copies = 1 + } + weighted += copies * paths[k] * v + } + if err := math.Abs(float64(weighted) - target); valid && weighted >= minimum && err < bestError { + best, bestError, bestWeighted = candidate, err, weighted + } } - return out + return best, bestWeighted } func absPlinko(n int) int { if n < 0 { @@ -67,6 +113,42 @@ func absPlinko(n int) int { } return n } + +// plinkoMultiplierText renders a played multiplier with at most one decimal. +func plinkoMultiplierText(multiplier float64) string { + return strconv.FormatFloat(multiplier, 'f', -1, 64) + "x" +} + +// plinkoReturns splits the actual payout across the distinct landed pockets. +// Each pocket's exact return is floored; leftover credits go to the largest +// fractional remainders, so the itemized receipt always sums to the payout. +func plinkoReturns(counts [17]int, betSize int, pockets [17]float64, paid int) map[int]int { + type share struct { + pocket, floor int + frac float64 + } + var shares []share + base := 0 + for p, count := range counts { + if count == 0 { + continue + } + exact := float64(betSize) * pockets[p] * float64(count) + floor := int(math.Floor(exact)) + shares = append(shares, share{pocket: p, floor: floor, frac: exact - float64(floor)}) + base += floor + } + sort.SliceStable(shares, func(i, j int) bool { return shares[i].frac > shares[j].frac }) + for i := 0; i < paid-base && i < len(shares); i++ { + shares[i].floor++ + } + returns := make(map[int]int, len(shares)) + for _, s := range shares { + returns[s.pocket] = s.floor + } + return returns +} + func plinkoReturn(risk int, rtp float64) float64 { pockets := plinkoPockets(risk, rtp) choose, result := 1.0, 0.0 @@ -84,21 +166,22 @@ func plinkoHouseEdge(c TableConfig, _ string) HouseEdge { for risk, name := range plinkoRisks { e.Parts = append(e.Parts, EdgePart{Label: name, Loss: 1 - plinkoReturn(risk, c.Plinko.Normalize().RTP)}) } - e.Note = "easy / medium / hard / expert; includes upward rounding to 0.1x" + e.Note = "easy / medium / hard / expert; actual returns from one-decimal paytables" return e } func plinkoRules(c TableConfig) string { - return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Base RTP %.4g%%. Multipliers round UP to 0.1x, increasing actual RTP by risk.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP) + return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Target RTP %.4g%%. One-decimal payouts are balanced to track that target.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP) } type PlinkoSnapshot struct { - // Row 0 is the apex (preceded by the payout legend); 16 is the pockets. + // Row 0 is the apex; 16 is the pockets. The legend runs alongside the board. Row, Total int Counts [17]int Pockets [17]float64 Paytable bool Risk string + RTP float64 // actual return percentage of Pockets } func (*PlinkoSnapshot) snapshot() {} @@ -180,14 +263,14 @@ func (t *plinkoTable) hints(n string) []MenuCommand { return t.hintsLocked() } func (t *plinkoTable) snap(paytable bool) *PlinkoSnapshot { - s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], Paytable: paytable} + s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], RTP: 100 * plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), Paytable: paytable} for _, p := range t.positions { s.Counts[p]++ } return s } func (t *plinkoTable) statusLocked(n string) []Event { - e := t.private(n, fmt.Sprintf("PLINKO / %s / %d balls / %.2f%% RTP (base %.4g%%)", plinkoRisks[t.risk], t.balls, 100*plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), t.Config.Plinko.Normalize().RTP)) + e := t.private(n, "") e.Color = "casino_menu" e.Snapshot = t.snap(true) if n == t.name { @@ -210,7 +293,7 @@ func (t *plinkoTable) join(n string) []Event { t.name = n t.risk = 0 t.balls = 1 - e := t.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.") + e := t.private(n, "") e.Type = EventJoined e.Player = n return append([]Event{e}, t.statusLocked(n)...) @@ -304,10 +387,10 @@ func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event { t.wallet = w t.stake = stake t.betSize = amount - t.row = plinkoFirstRow + t.row = 0 t.active = true t.positions = make([]int, t.balls) - e := t.private(n, fmt.Sprintf("*** PLINKO / %s *** %d balls x %d = %d credits. Let it rain!", strings.ToUpper(plinkoRisks[t.risk]), t.balls, amount, stake.Total)) + e := t.private(n, fmt.Sprintf("Stake: %d x %d = %d credits.", t.balls, amount, stake.Total)) e.Snapshot = t.snap(false) return []Event{e} } @@ -318,10 +401,8 @@ func (t *plinkoTable) tick() []Event { return nil } t.row++ - if t.row > 0 { - for i := range t.positions { - t.positions[i] += t.rng.Intn(2) - } + for i := range t.positions { + t.positions[i] += t.rng.Intn(2) } e := t.private(t.name, "") e.Snapshot = t.snap(false) @@ -333,11 +414,10 @@ func (t *plinkoTable) tick() []Event { pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP) labels := PlinkoPocketLabels(pockets) total := 0.0 - var hits []string - for i, p := range t.positions { - v := pockets[p] * float64(t.betSize) - total += v - hits = append(hits, fmt.Sprintf("Ball %d -> [%c] %.1fx", i+1, labels[p], pockets[p])) + var counts [17]int + for _, p := range t.positions { + counts[p]++ + total += pockets[p] * float64(t.betSize) } paid := int(math.Floor(total)) if t.rng.Float64() < total-float64(paid) { @@ -348,19 +428,44 @@ func (t *plinkoTable) tick() []Event { } t.stake = Wager{} result := t.private(t.name, "") - outcome := "loss" - if paid == t.betSize*t.balls { - outcome = "push" - } else if paid > t.betSize*t.balls { - outcome = "win" + // Itemize the receipt by landed payout so each distinct multiplier is a + // visible row, instead of a separate "Ball N -> [X]" line per ball. + returns := plinkoReturns(counts, t.betSize, pockets, paid) + type payoutRow struct { + multiplier float64 + stake, returned int + } + rows := make(map[rune]*payoutRow) + var order []rune + for p, count := range counts { + if count == 0 { + continue + } + row, ok := rows[labels[p]] + if !ok { + row = &payoutRow{multiplier: pockets[p]} + rows[labels[p]] = row + order = append(order, labels[p]) + } + row.stake += t.betSize * count + row.returned += returns[p] + } + result.Settlement = make([]SettlementLine, 0, len(order)) + for _, label := range order { + row := rows[label] + result.Settlement = append(result.Settlement, SettlementLine{ + Label: fmt.Sprintf("[%c] %s", label, plinkoMultiplierText(row.multiplier)), + Odds: "Varies", + Stake: row.stake, + Returned: row.returned, + Result: returnResult(row.stake, row.returned), + }) } - result.Settlement = []SettlementLine{{Label: fmt.Sprintf("%d Plinko balls", t.balls), Odds: "Varies", Stake: t.betSize * t.balls, Returned: paid, Result: outcome}} result.Color = "casino_payout" if paid > t.betSize*t.balls { result.Color = "casino_big_win" } - // The shared receipt replaces Message, so keep pocket reveals in their own event. - events = append(events, t.private(t.name, strings.Join(hits, "\n")), result, t.betMenuEvent(t.name, t.hintsLocked())) + events = append(events, result, t.betMenuEvent(t.name, t.hintsLocked())) return events } func (t *plinkoTable) markDisconnected(n string) []Event { diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go index 2b79853..6ddfee5 100644 --- a/internal/casino/plinko_test.go +++ b/internal/casino/plinko_test.go @@ -27,8 +27,14 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) { t.Fatalf("pockets must grow toward edges: %v", p) } } - if mean < rtp/100 || mean >= rtp/100+0.1 { - t.Fatalf("risk %d rounded RTP %g not within [base, base+0.1)", risk, mean) + if math.Abs(mean-rtp/100) > 0.01 { + t.Fatalf("risk %d RTP %.4f%% is too far from target %.2f%%", risk, mean*100, rtp) + } + if rtp == 98 { + if mean < 0.97 || mean > 0.99 { + t.Fatalf("risk %d default RTP %.4f%% outside 97–99%%", risk, mean*100) + } + t.Logf("%s RTP: %.6f%%", plinkoRisks[risk], mean*100) } if math.Abs(mean-plinkoReturn(risk, rtp)) > 1e-12 { t.Fatal("reported RTP differs from played pockets") @@ -49,6 +55,20 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) { } } } +func TestPlinkoRTPNeverFallsWithRisk(t *testing.T) { + // Include fractional builder-configured targets as well as the default. + for target := 31.0; target <= 100; target += 0.25 { + previous := 0.0 + for risk := range plinkoRisks { + actual := plinkoReturn(risk, target) + if actual+1e-12 < previous { + t.Fatalf("target %.2f%%: %s RTP %.6f%% fell below %.6f%%", target, plinkoRisks[risk], actual*100, previous*100) + } + previous = actual + } + } +} + func newTestPlinko() *plinkoTable { return newTable(1, TableConfig{ID: "plinko", Game: GamePlinko}, rand.New(rand.NewSource(42))).game.(*plinkoTable) } @@ -68,7 +88,7 @@ func TestPlinkoDropLifecycle(t *testing.T) { if !p.active || p.balls != 10 || len(w.open) != 1 { t.Fatal("active drop changed") } - for row := plinkoFirstRow + 1; row <= 16; row++ { + for row := 1; row <= 16; row++ { old := append([]int(nil), p.positions...) events := p.tick() s := events[0].Snapshot.(*PlinkoSnapshot) @@ -80,9 +100,6 @@ func TestPlinkoDropLifecycle(t *testing.T) { t.Fatal("wrong animation") } for i, pos := range p.positions { - if row <= 0 && pos != old[i] { - t.Fatal("ball moved during label prelude") - } if pos-old[i] < 0 || pos-old[i] > 1 { t.Fatal("not a left/right bounce") } @@ -130,10 +147,11 @@ func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) { p.action("alice", "difficulty expert") w := &testWallet{credits: 100} p.bet("alice", "", 10, w) - // Both possible final center pockets round up to 0.1x, not just in the UI. + // Expert center pockets pay 0x; the displayed one-decimal table is played + // exactly, without silently rounding a losing pocket upward. p.row, p.positions = 15, []int{7} events := p.tick() - if w.paid != 1 || len(w.open) != 0 { + if w.paid != 0 || len(w.open) != 0 { t.Fatalf("rounded pocket not paid: %+v", w) } for _, e := range events { @@ -172,14 +190,86 @@ func TestPlinkoSharedPocketLabels(t *testing.T) { p.bet("alice", "", 1, w) p.row, p.positions = 15, []int{15} events := p.tick() - labels := PlinkoPocketLabels(plinkoPockets(0, 98)) - want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]]) + pockets := plinkoPockets(0, 98) + labels := PlinkoPocketLabels(pockets) + want := fmt.Sprintf("[%c] %s", labels[p.positions[0]], plinkoMultiplierText(pockets[p.positions[0]])) found := false for _, e := range events { - found = found || strings.Contains(e.Message, want) + for _, line := range e.Settlement { + found = found || line.Label == want + } } if !found { - t.Fatalf("settlement did not use mirrored label %s", want) + t.Fatalf("settlement did not itemize mirrored label %s", want) + } +} + +func TestPlinkoReturnsItemizeEveryPocket(t *testing.T) { + pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} + counts := [17]int{0: 1, 7: 2, 16: 1} + total := 0.0 + for p, count := range counts { + total += pockets[p] * float64(count) * 3 + } + paid := int(math.Floor(total)) + returns := plinkoReturns(counts, 3, pockets, paid) + sum := 0 + for p, count := range counts { + if count == 0 { + continue + } + if returns[p] < 0 { + t.Fatalf("negative return for pocket %d", p) + } + sum += returns[p] + } + if sum != paid { + t.Fatalf("itemized returns %d do not sum to paid %d", sum, paid) + } + if returns[16] <= returns[7] { + t.Fatalf("higher pocket %d must out-return %d", returns[16], returns[7]) + } + if whole := plinkoReturns([17]int{0: 2}, 5, pockets, 160); whole[0] != 160 { + t.Fatalf("whole return changed: %d", whole[0]) + } +} + +func TestPlinkoReceiptMergesSharedPayouts(t *testing.T) { + p := newTestPlinko() + p.join("alice") + p.action("alice", "balls 4") + w := &testWallet{credits: 100} + p.bet("alice", "", 5, w) + if len(w.open) != 1 || w.open[0].Total != 20 { + t.Fatal("batch stake wrong") + } + // Mirrored pockets 1 and 15 share payout B; two balls in center pocket 8. + // newTestPlinko seeds the RNG at 42 and the drop is its first consumer, so + // undo the first four bounces to land the sealed balls on named pockets. + bounces := rand.New(rand.NewSource(42)) + p.positions = []int{1 - bounces.Intn(2), 15 - bounces.Intn(2), 8 - bounces.Intn(2), 8 - bounces.Intn(2)} + p.row = 15 + events := p.tick() + if p.positions[0] != 1 || p.positions[1] != 15 { + t.Fatalf("unexpected final pockets: %v", p.positions) + } + var lines []SettlementLine + for _, e := range events { + lines = append(lines, e.Settlement...) + } + if len(lines) != 2 { + t.Fatalf("want one row per distinct payout, got %d: %+v", len(lines), lines) + } + staked, returned := 0, 0 + for _, line := range lines { + staked += line.Stake + returned += line.Returned + } + if staked != 20 || returned != 80 || w.paid != 80 { + t.Fatalf("merged receipt mismatch: staked %d returned %d paid %d", staked, returned, w.paid) + } + if lines[0].Label != "[B] 7.8x" || lines[0].Returned != 78 || lines[1].Label != "[I] 0.2x" || lines[1].Returned != 2 { + t.Fatalf("unexpected rows: %+v", lines) } } @@ -205,7 +295,7 @@ func TestPlinkoFailedPayoutRetainsStake(t *testing.T) { p.join("alice") w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} p.bet("alice", "", 10, w) - for i := plinkoFirstRow; i < 16; i++ { + for i := 0; i < 16; i++ { p.tick() } if len(w.open) != 1 { diff --git a/internal/casino/registry.go b/internal/casino/registry.go index fdaecec..b13125d 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -44,6 +44,15 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GameCashHunt: { + validate: validateCashHunt, + normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: newCashHuntTable, + ruleText: cashHuntRules, + helpTopic: func(TableConfig) string { return "cash hunt" }, + houseEdge: func(TableConfig, string) HouseEdge { return edge(0.02) }, + }, GamePlinko: { validate: validatePlinko, normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c }, diff --git a/internal/casino/types.go b/internal/casino/types.go index 5f4d4f8..f6a828d 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -18,6 +18,7 @@ const ( GameVideoPoker GameName = "video_poker" GameSlots GameName = "slots" GamePlinko GameName = "plinko" + GameCashHunt GameName = "cash_hunt" GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" -- cgit v1.2.3