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.go116
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 {