package casino import ( "fmt" "math" "math/rand" "strings" "testing" ) func TestPlinkoRoundedRTPAndVariance(t *testing.T) { for _, rtp := range []float64{31, 90, 98, 100} { lastVariance := -1.0 for risk := 0; risk < 4; risk++ { p := plinkoPockets(risk, rtp) choose, mean, second := 1.0, 0.0, 0.0 for k, v := range p { if k > 0 { choose *= float64(17-k) / float64(k) } mean += choose * v / 65536 second += choose * v * v / 65536 if v < 0 || v != p[16-k] { t.Fatalf("invalid symmetric pockets %v", p) } if k < 8 && v < p[k+1] { t.Fatalf("pockets must grow toward edges: %v", p) } } 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") } for _, v := range p { if math.Abs(v*10-math.Round(v*10)) > 1e-9 { t.Fatalf("not a tenth: %g", v) } } variance := second - mean*mean if variance <= lastVariance { t.Fatalf("variance did not rise: %g <= %g", variance, lastVariance) } lastVariance = variance if risk == 0 && p[0] != 16 || risk == 3 && p[0] != 10000 { t.Fatal("wrong jackpot") } } } } 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) } func TestPlinkoDropLifecycle(t *testing.T) { p := newTestPlinko() p.join("alice") p.action("alice", "balls 10") p.action("alice", "difficulty expert") w := &testWallet{credits: 1000} p.bet("alice", "", 10, w) if len(w.open) != 1 || w.open[0].Total != 100 { t.Fatal("batch must wager once") } p.bet("alice", "", 10, w) p.action("alice", "balls 2") p.leave("alice") if !p.active || p.balls != 10 || len(w.open) != 1 { t.Fatal("active drop changed") } for row := 1; row <= 16; row++ { old := append([]int(nil), p.positions...) events := p.tick() s := events[0].Snapshot.(*PlinkoSnapshot) sum := 0 for _, n := range s.Counts { sum += n } if s.Row != row || sum != 10 { t.Fatal("wrong animation") } for i, pos := range p.positions { if pos-old[i] < 0 || pos-old[i] > 1 { t.Fatal("not a left/right bounce") } } } if p.active || len(w.open) != 0 { t.Fatal("unsettled drop") } paid := w.paid p.tick() p.refundAll() if w.paid != paid { t.Fatal("duplicate payout") } } func TestPlinkoRefundAndDisconnect(t *testing.T) { for _, disconnect := range []bool{false, true} { p := newTestPlinko() p.join("alice") w := &testWallet{credits: 100} p.bet("alice", "", 10, w) p.tick() if disconnect { p.markDisconnected("alice") } else { p.refundAll() } if len(w.open) != 0 || !p.idle() { t.Fatal("stake or seat left open") } if !disconnect && w.credits+w.paid != 100 { t.Fatal("shutdown failed to refund") } if disconnect && w.credits != 90 { t.Fatal("disconnect must forfeit") } if len(p.tick()) != 0 { t.Fatal("discarded drop continued") } } } func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) { p := newTestPlinko() p.join("alice") p.action("alice", "difficulty expert") w := &testWallet{credits: 100} p.bet("alice", "", 10, w) // 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 != 0 || len(w.open) != 0 { t.Fatalf("rounded pocket not paid: %+v", w) } for _, e := range events { if strings.Contains(e.Message, "credits returned") { t.Fatal("duplicate payout prose") } } table := &Table{tableBase: p.tableBase, game: p} want := 1 - plinkoReturn(3, 98) if got := table.HouseEdge("alice", "main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-want) > 1e-12 { t.Fatalf("seat edge ignores rounded payouts: %+v", got) } if got := p.Config.HouseEdge("main"); len(got.Parts) != 4 || math.Abs(got.Parts[3].Loss-want) > 1e-12 { t.Fatalf("config edge ignores risk: %+v", got) } } func TestPlinkoSharedPocketLabels(t *testing.T) { for risk := 0; risk < 4; risk++ { pockets := plinkoPockets(risk, 98) labels := PlinkoPocketLabels(pockets) for i := range pockets { for j := range pockets { if (pockets[i] == pockets[j]) != (labels[i] == labels[j]) { t.Fatalf("risk %d: payout/label mismatch at %d,%d", risk, i, j) } } } if labels[0] != 'A' || labels[16] != 'A' { t.Fatal("both edges must be A") } } p := newTestPlinko() p.join("alice") w := &testWallet{credits: 100} p.bet("alice", "", 1, w) p.row, p.positions = 15, []int{15} events := p.tick() 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 { for _, line := range e.Settlement { found = found || line.Label == want } } if !found { 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) } } func TestPlinkoConfig(t *testing.T) { for _, rtp := range []float64{-1, 30, 101, math.NaN(), math.Inf(1)} { if (TableConfig{ID: "p", Game: GamePlinko, Plinko: PlinkoConfig{RTP: rtp}}).Validate() == nil { t.Fatalf("accepted RTP %g", rtp) } } c := TableConfig{ID: "p", Game: GamePlinko} if err := c.Validate(); err != nil { t.Fatal(err) } if c.Normalize().Plinko.RTP != 98 { t.Fatal("wrong default") } if (TableConfig{ID: "p", Game: GameRoulette, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil { t.Fatal("wrong rule block accepted") } } func TestPlinkoFailedPayoutRetainsStake(t *testing.T) { p := newTestPlinko() p.join("alice") w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} p.bet("alice", "", 10, w) for i := 0; i < 16; i++ { p.tick() } if len(w.open) != 1 { t.Fatal("failed settlement consumed stake") } p.leave("alice") if len(w.open) != 1 { t.Fatal("leave consumed failed stake") } }