aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/cash_hunt_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/cash_hunt_test.go')
-rw-r--r--internal/casino/cash_hunt_test.go265
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")
+ }
+}