diff options
| author | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-09 12:32:48 -0400 |
| commit | e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3 (patch) | |
| tree | 512ee9e1ce9333670553d90efe55e91d8ecff0fd /internal/casino/pachinko_test.go | |
| parent | 9666676766bf21364f4fc4d0d1d336e72f2a91b4 (diff) | |
| download | thehouseoficarus-e95b15ed51832e46c7d0d90f3c84d0ac0b65fbe3.tar.gz | |
feat: pachinko slots randomized, 9th slot removed, RTP set to 100% if played perfectly
Diffstat (limited to 'internal/casino/pachinko_test.go')
| -rw-r--r-- | internal/casino/pachinko_test.go | 334 |
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") + } +} |
