aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pachinko_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/pachinko_test.go')
-rw-r--r--internal/casino/pachinko_test.go334
1 files changed, 334 insertions, 0 deletions
diff --git a/internal/casino/pachinko_test.go b/internal/casino/pachinko_test.go
new file mode 100644
index 0000000..ef20503
--- /dev/null
+++ b/internal/casino/pachinko_test.go
@@ -0,0 +1,334 @@
+package casino
+
+import (
+ "math"
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func newTestPachinko() *pachinkoTable {
+ return newTable(1, TableConfig{ID: "pachinko", Game: GamePachinko}, rand.New(rand.NewSource(42))).game.(*pachinkoTable)
+}
+
+func TestPachinkoRTP(t *testing.T) {
+ rtp := pachinkoOptimalRTP()
+ t.Logf("optimal RTP %.6f, uniform RTP %.6f", rtp, pachinkoUniformRTP)
+ if math.Abs(rtp-1.0) > 0.01 {
+ t.Fatalf("best-slot RTP %.6f should be 100%%", rtp)
+ }
+ if pachinkoUniformRTP < 0.75 || pachinkoUniformRTP > 0.90 {
+ t.Fatalf("blind RTP %.6f outside the expected band", pachinkoUniformRTP)
+ }
+ if rtp <= pachinkoUniformRTP {
+ t.Fatalf("choosing the best slot must beat a random slot: %.6f <= %.6f", rtp, pachinkoUniformRTP)
+ }
+ e := pachinkoHouseEdge(TableConfig{}, "")
+ if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-math.Max(0, 1-rtp)) > 1e-12 {
+ t.Fatalf("house edge does not match the optimal return: %+v", e)
+ }
+ // Two reads must return the same cached value.
+ if again := pachinkoOptimalRTP(); again != rtp {
+ t.Fatalf("cached RTP changed: %.12f -> %.12f", rtp, again)
+ }
+}
+
+func TestPachinkoBoardValue(t *testing.T) {
+ reach := pachinkoReach()
+ for start := range reach {
+ sum := 0.0
+ for _, p := range reach[start] {
+ sum += p
+ }
+ if math.Abs(sum-1) > 1e-9 {
+ t.Fatalf("reach row %d sums to %.12f", start, sum)
+ }
+ }
+ var pockets [pachinkoLanes]PachinkoPocket
+ for i := range pockets {
+ pockets[i] = PachinkoPocket{Value: 1}
+ }
+ if got := pachinkoBoardValue(&reach, &pockets); math.Abs(got-1) > 1e-9 {
+ t.Fatalf("all-1x board worth %.12f, want 1", got)
+ }
+ pockets = [pachinkoLanes]PachinkoPocket{}
+ pockets[0] = PachinkoPocket{Value: 100}
+ want := 0.0
+ for start := 0; start < pachinkoLanes; start++ {
+ if value := 100.0 * reach[start][0]; value > want {
+ want = value
+ }
+ }
+ if got := pachinkoBoardValue(&reach, &pockets); math.Abs(got-want) > 1e-9 {
+ t.Fatalf("lone jackpot worth %.12f, want %.12f", got, want)
+ }
+}
+
+func TestPachinkoReachCoversEveryPocket(t *testing.T) {
+ // The half-lane walk must not lock onto a parity: from every top slot the
+ // ball must be able to reach every pocket, or half the board is dead.
+ reach := pachinkoReach()
+ for start := 0; start < pachinkoLanes; start++ {
+ for pocket := 0; pocket < pachinkoLanes; pocket++ {
+ if reach[start][pocket] <= 0 {
+ t.Fatalf("slot %d can never land in pocket %d", start+1, pocket+1)
+ }
+ }
+ }
+}
+
+func TestPachinkoPremiumLandsInEverySlot(t *testing.T) {
+ // The premium is chosen first and then shuffled, so it must appear in every
+ // slot, not only the last one.
+ p := newTestPachinko()
+ seen := [pachinkoLanes]bool{}
+ for i := 0; i < 5000; i++ {
+ p.dealLocked()
+ for lane, pocket := range p.pockets {
+ if !pocket.Double && pocket.Value > 1.5 {
+ seen[lane] = true
+ }
+ }
+ }
+ for lane, ok := range seen {
+ if !ok {
+ t.Fatalf("premium never landed in slot %d", lane+1)
+ }
+ }
+}
+
+func TestPachinkoBoardComposition(t *testing.T) {
+ // Every dealt board has exactly one guaranteed pocket above 1.5x, at most
+ // one DOUBLE, and never an all-low board without a premium.
+ p := newTestPachinko()
+ seenFive, seenTen, seenHundred := false, false, false
+ for i := 0; i < 20000; i++ {
+ p.dealLocked()
+ premium, doubles := 0, 0
+ for _, pocket := range p.pockets {
+ if pocket.Double {
+ doubles++
+ continue
+ }
+ switch {
+ case pocket.Value >= 100:
+ premium++
+ seenHundred = true
+ case pocket.Value >= 10:
+ premium++
+ seenTen = true
+ case pocket.Value > 1.5:
+ premium++
+ seenFive = true
+ }
+ }
+ if premium != 1 {
+ t.Fatalf("board must carry exactly one >1.5x pocket: %+v", p.pockets)
+ }
+ if doubles > 1 {
+ t.Fatalf("board carried more than one DOUBLE: %+v", p.pockets)
+ }
+ }
+ if !seenFive || !seenTen || !seenHundred {
+ t.Fatalf("premium tiers never all appeared: 5x=%v 10x=%v 100x=%v", seenFive, seenTen, seenHundred)
+ }
+}
+
+func TestPachinkoConfig(t *testing.T) {
+ if (TableConfig{ID: "p", Game: GamePachinko, MaxPlayers: 2}).Validate() == nil {
+ t.Fatal("accepted a two-seat pachinko table")
+ }
+ c := TableConfig{ID: "p", Game: GamePachinko}
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if got := c.Normalize(); got.MaxPlayers != 1 {
+ t.Fatalf("normalized max players = %d, want 1", got.MaxPlayers)
+ }
+ if (TableConfig{ID: "p", Game: GamePachinko, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil {
+ t.Fatal("accepted a plinko rule block on a pachinko table")
+ }
+ // The registry supplies a rule page and an edge analysis.
+ def, ok := tableGameDefFor(GamePachinko)
+ if !ok || def.ruleText == nil || def.houseEdge == nil || def.hasRuleBlock == nil || def.hasRuleBlock(TableConfig{}) {
+ t.Fatalf("pachinko registration incomplete: %+v", def.ruleBlockName)
+ }
+}
+
+func TestPachinkoDropLifecycle(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 1000}
+ p.bet("alice", "", 10, w)
+ if p.phase != pachinkoChoosing || p.name != "alice" || len(w.open) != 1 {
+ t.Fatalf("bet did not set up the board: phase %q open %d", p.phase, len(w.open))
+ }
+ // Force a deterministic 2x board so the payout does not depend on the deal.
+ for i := range p.pockets {
+ p.pockets[i] = PachinkoPocket{Value: 2}
+ }
+ events := p.action("alice", "drop 5")
+ if p.phase != pachinkoDropping || p.slot != 5 || p.lane != 4 || p.row != 0 {
+ t.Fatalf("drop did not start from slot 5: %+v", p)
+ }
+ if len(events) == 0 || events[0].Snapshot.(*PachinkoSnapshot).Row != 0 {
+ t.Fatal("drop did not publish the top row")
+ }
+ for row := 1; row <= pachinkoRows; row++ {
+ events = p.tick()
+ if len(events) == 0 {
+ t.Fatalf("row %d produced no event", row)
+ }
+ // The ball falls exactly one row per game tick.
+ snap, ok := events[0].Snapshot.(*PachinkoSnapshot)
+ if !ok || snap.Row != row {
+ t.Fatalf("tick %d advanced to row %d, want %d", row, snap.Row, row)
+ }
+ }
+ if p.phase != pachinkoIdle || len(w.open) != 0 {
+ t.Fatalf("round did not settle: phase %q open %d", p.phase, len(w.open))
+ }
+ if w.paid != 20 {
+ t.Fatalf("2x on a 10-credit bet paid %d, want 20", w.paid)
+ }
+ // No further ticks may pay again.
+ before := w.paid
+ p.tick()
+ if w.paid != before {
+ t.Fatal("duplicate payout after settlement")
+ }
+}
+
+func TestPachinkoDoubleChains(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ p.pockets = [pachinkoLanes]PachinkoPocket{}
+ p.pockets[4] = PachinkoPocket{Double: true}
+ p.pockets[0] = PachinkoPocket{Value: 10}
+
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 4, 8
+ p.slot = 5
+ p.mu.Lock()
+ events := p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoChoosing || p.doubling != 1 {
+ t.Fatalf("DOUBLE did not double and re-drop: phase %q doubling %d", p.phase, p.doubling)
+ }
+ if len(events) == 0 || !strings.Contains(events[0].Message, "DOUBLE") {
+ t.Fatalf("DOUBLE was not announced: %+v", events)
+ }
+ // The board display doubles the numeric prizes.
+ if got := pachinkoPocketText(p.pockets[0], p.doubling); got != "20x" {
+ t.Fatalf("doubled 10x displays as %q, want 20x", got)
+ }
+ // A second DOUBLE at the same lane doubles again instead of paying.
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 4, 8
+ p.mu.Lock()
+ p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoChoosing || p.doubling != 2 {
+ t.Fatalf("chained DOUBLE failed: phase %q doubling %d", p.phase, p.doubling)
+ }
+ // Landing on the 10x now pays 10 * 2^2 = 40x, i.e. 400 on a 10 stake.
+ p.phase = pachinkoDropping
+ p.lane, p.pos = 0, 0
+ p.mu.Lock()
+ events = p.resolveLocked()
+ p.mu.Unlock()
+ if p.phase != pachinkoIdle || w.paid != 400 || len(w.open) != 0 {
+ t.Fatalf("doubled payout wrong: phase %q paid %d open %d", p.phase, w.paid, len(w.open))
+ }
+ if len(events[0].Settlement) != 1 || events[0].Settlement[0].Returned != 400 {
+ t.Fatalf("doubled settlement missing: %+v", events[0].Settlement)
+ }
+}
+
+func TestPachinkoMultiplierAndRounding(t *testing.T) {
+ if got := pachinkoMultiplier(100, 0); got != 100 {
+ t.Fatalf("plain jackpot = %v, want 100", got)
+ }
+ if got := pachinkoMultiplier(100, 3); got != 800 {
+ t.Fatalf("3-doubled jackpot = %v, want 800", got)
+ }
+ if got := pachinkoMultiplier(100, 40); got != float64(MaxCredits) {
+ t.Fatalf("overflowing multiplier = %v, want the MaxCredits cap", got)
+ }
+ if got := pachinkoPocketText(PachinkoPocket{Value: 0.1}, 1); got != "0.2x" {
+ t.Fatalf("doubled 0.1x displays as %q, want 0.2x", got)
+ }
+ // A 0.1x return on a one-credit bet must average exactly 0.1 credits.
+ p := newTestPachinko()
+ p.betSize = 1
+ total := 0
+ const trials = 200000
+ for i := 0; i < trials; i++ {
+ total += p.payoutLocked(0.1)
+ }
+ if avg := float64(total) / trials; math.Abs(avg-0.1) > 0.01 {
+ t.Fatalf("0.1x rounded to %.4f on average, want 0.1", avg)
+ }
+}
+
+func TestPachinkoRefundAndDisconnect(t *testing.T) {
+ for _, disconnect := range []bool{false, true} {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ 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.Fatalf("shutdown failed to refund: credits %d paid %d", w.credits, w.paid)
+ }
+ if disconnect && w.credits != 90 {
+ t.Fatalf("disconnect must forfeit: credits %d", w.credits)
+ }
+ if len(p.tick()) != 0 {
+ t.Fatal("discarded board continued")
+ }
+ }
+}
+
+func TestPachinkoFailedPayoutRetainsStake(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ p.bet("alice", "", 10, w)
+ for i := range p.pockets {
+ p.pockets[i] = PachinkoPocket{Value: 2}
+ }
+ p.action("alice", "drop 1")
+ for i := 0; i < pachinkoRows; i++ {
+ p.tick()
+ }
+ if p.phase != pachinkoIdle || len(w.open) != 1 {
+ t.Fatalf("failed payout consumed the stake or stuck the phase: %q open %d", p.phase, len(w.open))
+ }
+ p.leave("alice")
+ if len(w.open) != 1 {
+ t.Fatal("leave consumed the failed stake")
+ }
+}
+
+func TestPachinkoLeaveBlockedMidBoard(t *testing.T) {
+ p := newTestPachinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ if events := p.leave("alice"); len(events) == 0 || !strings.Contains(events[0].Message, "Finish") {
+ t.Fatalf("leaving a committed board must be blocked: %+v", events)
+ }
+ if p.name != "alice" {
+ t.Fatal("blocked leave freed the seat")
+ }
+}