diff options
| author | historia <[not public]> | 2026-10-03 19:46:54 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-03 19:46:54 -0400 |
| commit | 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (patch) | |
| tree | 9ba1cd5f2aef6ef7c54fdf5b02a780604b5af80b /internal/casino/plinko_test.go | |
| parent | c6d4702c0be4d2b37e1d82ff96265c52847c3f9a (diff) | |
| download | thehouseoficarus-3a39a020e4449bc4d6f728ef837cffb9f7bd0c56.tar.gz | |
feat: new casino game: plinko
Diffstat (limited to 'internal/casino/plinko_test.go')
| -rw-r--r-- | internal/casino/plinko_test.go | 218 |
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") + } +} |
