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") } }