diff options
Diffstat (limited to 'internal/casino/plinko_test.go')
| -rw-r--r-- | internal/casino/plinko_test.go | 116 |
1 files changed, 103 insertions, 13 deletions
diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go index 2b79853..6ddfee5 100644 --- a/internal/casino/plinko_test.go +++ b/internal/casino/plinko_test.go @@ -27,8 +27,14 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) { 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-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") @@ -49,6 +55,20 @@ func TestPlinkoRoundedRTPAndVariance(t *testing.T) { } } } +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) } @@ -68,7 +88,7 @@ func TestPlinkoDropLifecycle(t *testing.T) { if !p.active || p.balls != 10 || len(w.open) != 1 { t.Fatal("active drop changed") } - for row := plinkoFirstRow + 1; row <= 16; row++ { + for row := 1; row <= 16; row++ { old := append([]int(nil), p.positions...) events := p.tick() s := events[0].Snapshot.(*PlinkoSnapshot) @@ -80,9 +100,6 @@ func TestPlinkoDropLifecycle(t *testing.T) { 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") } @@ -130,10 +147,11 @@ func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) { 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. + // 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 != 1 || len(w.open) != 0 { + if w.paid != 0 || len(w.open) != 0 { t.Fatalf("rounded pocket not paid: %+v", w) } for _, e := range events { @@ -172,14 +190,86 @@ func TestPlinkoSharedPocketLabels(t *testing.T) { 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]]) + 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 { - found = found || strings.Contains(e.Message, want) + for _, line := range e.Settlement { + found = found || line.Label == want + } } if !found { - t.Fatalf("settlement did not use mirrored label %s", want) + 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) } } @@ -205,7 +295,7 @@ func TestPlinkoFailedPayoutRetainsStake(t *testing.T) { p.join("alice") w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} p.bet("alice", "", 10, w) - for i := plinkoFirstRow; i < 16; i++ { + for i := 0; i < 16; i++ { p.tick() } if len(w.open) != 1 { |
