aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/plinko_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/plinko_test.go')
-rw-r--r--internal/casino/plinko_test.go218
1 files changed, 218 insertions, 0 deletions
diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go
new file mode 100644
index 0000000..2b79853
--- /dev/null
+++ b/internal/casino/plinko_test.go
@@ -0,0 +1,218 @@
+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")
+ }
+}