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") } }