diff options
Diffstat (limited to 'internal/casino/cash_hunt_test.go')
| -rw-r--r-- | internal/casino/cash_hunt_test.go | 265 |
1 files changed, 265 insertions, 0 deletions
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") + } +} |
