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 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-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 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 := plinkoFirstRow + 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 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") } } } 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) // Both possible final center pockets round up to 0.1x, not just in the UI. p.row, p.positions = 15, []int{7} events := p.tick() if w.paid != 1 || 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() labels := PlinkoPocketLabels(plinkoPockets(0, 98)) want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]]) found := false for _, e := range events { found = found || strings.Contains(e.Message, want) } if !found { t.Fatalf("settlement did not use mirrored label %s", want) } } 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 := plinkoFirstRow; 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") } }