aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/crazy_time_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-10 04:08:09 -0400
committerhistoria <[not public]>2026-10-10 04:08:09 -0400
commit9fb1f9010526100e12dd1ab1b0ca98401bb6ed75 (patch)
tree4a128211bfb0dd429bbb559a591d6c4be25c7757 /internal/casino/crazy_time_test.go
parentced77d7c89129da6ba4001ab8e3dad265aa41396 (diff)
downloadthehouseoficarus-9fb1f9010526100e12dd1ab1b0ca98401bb6ed75.tar.gz
feat: add casino game: crazy timeHEADmain
Diffstat (limited to 'internal/casino/crazy_time_test.go')
-rw-r--r--internal/casino/crazy_time_test.go323
1 files changed, 323 insertions, 0 deletions
diff --git a/internal/casino/crazy_time_test.go b/internal/casino/crazy_time_test.go
new file mode 100644
index 0000000..d783cc1
--- /dev/null
+++ b/internal/casino/crazy_time_test.go
@@ -0,0 +1,323 @@
+package casino
+
+import (
+ "math"
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func newCrazyTimeTest() *crazyTimeTable {
+ return newCrazyTimeTable(1, TableConfig{ID: "crazy", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(99)))
+}
+
+func TestCrazyTimeRegistrationAndRTP(t *testing.T) {
+ cfg := TableConfig{ID: "crazy", Game: GameCrazyTime}
+ if err := cfg.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Normalize().MaxPlayers != 1 {
+ t.Fatal("crazy time must be single-seat")
+ }
+ if cfg.HelpTopic() != "crazy time" {
+ t.Fatalf("help topic %q", cfg.HelpTopic())
+ }
+ if newTable(1, cfg, newTestRand()) == nil {
+ t.Fatal("registration failed")
+ }
+ for _, spot := range crazyTimeSpots() {
+ rtp := crazyTimeRTPTargetFor(spot.Label)
+ if math.Abs(rtp-crazyTimeRTPTarget) > 1e-9 {
+ t.Fatalf("%s RTP = %.12f, want %.12f", spot.Label, rtp, crazyTimeRTPTarget)
+ }
+ e := cfg.HouseEdge(spot.Label)
+ if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-crazyTimeRTPTarget)) > 1e-9 {
+ t.Fatalf("%s edge %+v", spot.Label, e)
+ }
+ dist := crazyTimeTopDists[spot.Label]
+ if dist.total <= 0 {
+ t.Fatalf("%s empty top dist", spot.Label)
+ }
+ for _, w := range dist.weights {
+ if w < 0 {
+ t.Fatalf("%s negative weight", spot.Label)
+ }
+ }
+ mean := 0.0
+ for i, w := range dist.weights {
+ mean += float64(dist.values[i]) * w
+ }
+ mean /= dist.total
+ if math.Abs(mean-crazyTimeTopTarget(spot.Label)) > 1e-9 {
+ t.Fatalf("%s top mean %.9f target %.9f", spot.Label, mean, crazyTimeTopTarget(spot.Label))
+ }
+ }
+}
+
+func TestCrazyTimeWheelCountsAndPositiveBonuses(t *testing.T) {
+ counts := map[string]int{}
+ for _, s := range crazyTimeWheel {
+ counts[s]++
+ }
+ if len(crazyTimeWheel) != crazyTimePockets {
+ t.Fatalf("wheel has %d pockets", len(crazyTimeWheel))
+ }
+ total := 0
+ for _, spot := range crazyTimeSpots() {
+ if counts[spot.Label] != spot.Count {
+ t.Fatalf("%s count %d want %d", spot.Label, counts[spot.Label], spot.Count)
+ }
+ total += spot.Count
+ }
+ if total != crazyTimePockets {
+ t.Fatalf("counts sum to %d", total)
+ }
+ // Every bonus distribution is strictly positive.
+ for _, v := range ctHuntValues {
+ if v < 1 {
+ t.Fatalf("cash hunt value %d is not positive", v)
+ }
+ }
+ for _, v := range ctPachinkoValues {
+ if v < 1 {
+ t.Fatalf("pachinko value %d is not positive", v)
+ }
+ }
+ for _, v := range ctCoinLowValues {
+ if v < 1 {
+ t.Fatalf("coin low value %d is not positive", v)
+ }
+ }
+ for _, v := range ctCoinHighValues {
+ if v < 1 {
+ t.Fatalf("coin high value %d is not positive", v)
+ }
+ }
+ for _, v := range ctBonusWheelValues {
+ if v < 1 {
+ t.Fatalf("crazy wheel value %d is not positive", v)
+ }
+ }
+}
+
+func TestCrazyTimeBetSpinSettle(t *testing.T) {
+ table := newCrazyTimeTest()
+ w := &recordingWallet{testWallet: testWallet{credits: 1000}}
+ table.join("a")
+ table.bet("a", "1", 10, w)
+ table.bet("a", "crazy time", 5, w)
+ if len(table.bets) != 2 || len(w.open) != 2 {
+ t.Fatalf("bets not committed: %+v open=%d", table.bets, len(w.open))
+ }
+ // Capture the fixed outcome before the wheel settles and resets it.
+ table.topSymbol, table.topMult = "2", 7
+ events := table.action("a", "spin")
+ if table.phase != ctSpinning || len(events) == 0 {
+ t.Fatal("spin did not start")
+ }
+ win := table.winSymbol
+ topSymbol, topMult := table.topSymbol, table.topMult
+ if win == "" {
+ t.Fatal("no winning symbol")
+ }
+ // Drive the wheel to completion.
+ for i := 0; i < 400 && table.phase == ctSpinning; i++ {
+ table.tick()
+ }
+ // A bonus may still be animating; drive it out too.
+ for i := 0; i < 1000 && table.phase == ctBonusing; i++ {
+ table.tick()
+ }
+ mult := 1
+ if topSymbol == win {
+ mult = topMult
+ }
+ want := 0
+ if win == "1" {
+ want += 10 * 2 * mult
+ }
+ if win == "CRAZY TIME" {
+ want += 5 * table.bonusValue * mult
+ }
+ if w.paid != want {
+ t.Fatalf("paid %d want %d (win=%s top=%s x%d)", w.paid, want, win, topSymbol, topMult)
+ }
+ if len(w.open) != 0 {
+ t.Fatalf("stakes left open: %d", len(w.open))
+ }
+}
+
+func TestCrazyTimeAllBonusesOnlyPayPositive(t *testing.T) {
+ for _, kind := range []string{ctBonusCoinFlip, ctBonusCashHunt, ctBonusPachinko, ctBonusWheel} {
+ for seed := int64(0); seed < 40; seed++ {
+ table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(seed+1)))
+ table.name = "a"
+ table.phase = ctBonusing
+ table.winSymbol = labelForBonus(kind)
+ table.topSymbol, table.topMult = "10", 1
+ switch kind {
+ case ctBonusCoinFlip:
+ table.buildCoinFlipLocked()
+ case ctBonusCashHunt:
+ table.buildCashHuntLocked()
+ case ctBonusPachinko:
+ table.buildPachinkoLocked()
+ case ctBonusWheel:
+ table.buildBonusWheelLocked()
+ }
+ if table.bonusValue < 1 {
+ t.Fatalf("%s produced %d", kind, table.bonusValue)
+ }
+ if len(table.prints) == 0 {
+ t.Fatalf("%s produced no frames", kind)
+ }
+ }
+ }
+}
+
+func labelForBonus(kind string) string {
+ for _, s := range crazyTimeSpots() {
+ if s.Bonus == kind {
+ return s.Label
+ }
+ }
+ return ""
+}
+
+func TestCrazyTimeUnbetRefunds(t *testing.T) {
+ table := newCrazyTimeTest()
+ w := &testWallet{credits: 100}
+ table.join("a")
+ table.bet("a", "5", 10, w)
+ if w.credits != 90 {
+ t.Fatalf("stake not debited: %d", w.credits)
+ }
+ table.action("a", "unbet")
+ if w.paid != 10 || len(w.open) != 0 {
+ t.Fatalf("unbet did not refund: paid=%d open=%d", w.paid, len(w.open))
+ }
+}
+
+func TestCrazyTimeAllBetAndRebet(t *testing.T) {
+ table := newCrazyTimeTest()
+ w := &testWallet{credits: 1000}
+ table.join("a")
+
+ events := table.bet("a", "all", 10, w)
+ if len(table.bets) != 4 {
+ t.Fatalf("ALL placed %d wagers, want 4", len(table.bets))
+ }
+ if w.credits != 960 || len(w.open) != 4 {
+ t.Fatalf("ALL debit: credits=%d open=%d", w.credits, len(w.open))
+ }
+ for _, e := range events {
+ if e.Menu != nil {
+ t.Fatalf("bet returned a menu (wallet would print): %+v", e)
+ }
+ }
+
+ // Settle on a losing number, then rebet every remembered minigame wager.
+ table.winSymbol = "10"
+ table.topSymbol, table.topMult = "1", 1
+ table.settleLocked()
+ if len(table.bets) != 0 {
+ t.Fatal("settlement did not clear the round")
+ }
+ before := w.credits
+ events = table.rebetAll("a", w)
+ if len(table.bets) != 4 {
+ t.Fatalf("rebet placed %d wagers, want 4", len(table.bets))
+ }
+ if w.credits != before-40 {
+ t.Fatalf("rebet debit: credits=%d want %d", w.credits, before-40)
+ }
+ for _, e := range events {
+ if e.Menu != nil {
+ t.Fatalf("rebet returned a menu: %+v", e)
+ }
+ }
+}
+
+func TestCrazyTimeResolveSpot(t *testing.T) {
+ cases := map[string]string{
+ "1": "1", "2": "2", "5": "5", "10": "10",
+ "co": "COIN FLIP", "coin flip": "COIN FLIP", "coinflip": "COIN FLIP",
+ "ca": "CASH HUNT", "cash hunt": "CASH HUNT",
+ "p": "PACHINKO", "pach": "PACHINKO",
+ "cr": "CRAZY TIME", "crazy time": "CRAZY TIME",
+ }
+ for in, want := range cases {
+ got, ok := crazyTimeResolveSpot(in)
+ if !ok || got != want {
+ t.Fatalf("%q -> %q,%v want %q", in, got, ok, want)
+ }
+ }
+ if _, ok := crazyTimeResolveSpot("c"); ok {
+ t.Fatal("ambiguous c should not resolve")
+ }
+}
+
+func TestCrazyTimeRulesText(t *testing.T) {
+ rules := crazyTimeRules(TableConfig{})
+ if !strings.Contains(rules, "98%") || strings.Contains(rules, "%%") {
+ t.Fatalf("rules text: %q", rules)
+ }
+ if !strings.Contains(rules, "CASH HUNT") || !strings.Contains(rules, "Top Slot") {
+ t.Fatalf("rules text missing content: %q", rules)
+ }
+}
+
+// TestCrazyTimeSettlementMath pins the payout arithmetic: a winning target
+// pays stake * total-return-multiplier * Top Slot multiplier, and a losing
+// target returns nothing.
+func TestCrazyTimeSettlementMath(t *testing.T) {
+ cases := []struct {
+ spot, win, topSym string
+ bonus, topMult int
+ stake, want int
+ }{
+ {"1", "1", "1", 0, 5, 10, 10 * 2 * 5},
+ {"1", "10", "1", 0, 5, 10, 0},
+ {"CASH HUNT", "CASH HUNT", "CASH HUNT", 3, 4, 7, 7 * 3 * 4},
+ {"CASH HUNT", "CASH HUNT", "COIN FLIP", 500, 9, 2, 2 * 500},
+ {"CRAZY TIME", "CRAZY TIME", "CRAZY TIME", 64, 50, 1, 64 * 50},
+ }
+ for _, tc := range cases {
+ table := newCrazyTimeTable(1, TableConfig{ID: "c", Game: GameCrazyTime, MinBet: 1, MaxBet: 100000}, rand.New(rand.NewSource(3)))
+ w := &testWallet{credits: MaxCredits / 2}
+ table.name = "a"
+ table.wallet = w
+ wager, err := w.Wager(tc.stake)
+ if err != nil {
+ t.Fatal(err)
+ }
+ table.bets = []crazyTimeBet{{spot: tc.spot, wager: wager}}
+ table.winSymbol = tc.win
+ table.bonusValue = tc.bonus
+ table.topSymbol, table.topMult = tc.topSym, tc.topMult
+ before := w.paid
+ table.settleLocked()
+ if got := w.paid - before; got != tc.want {
+ t.Fatalf("%s win=%s bonus=%d top=%s x%d stake=%d paid %d want %d", tc.spot, tc.win, tc.bonus, tc.topSym, tc.topMult, tc.stake, got, tc.want)
+ }
+ }
+}
+
+func TestCrazyTimeSpinDelaysFirstMove(t *testing.T) {
+ table := newCrazyTimeTest()
+ w := &testWallet{credits: 1000}
+ table.join("a")
+ table.bet("a", "1", 10, w)
+ events := table.action("a", "spin")
+ if len(events) == 0 || events[0].Snapshot == nil {
+ t.Fatal("spin did not print the opening wheel")
+ }
+ // The spin command and the first engine tick share a cycle, so the opening
+ // position must survive that tick before the wheel advances.
+ if ev := table.tick(); len(ev) != 0 {
+ t.Fatalf("first tick advanced the wheel immediately: %+v", ev)
+ }
+ if ev := table.tick(); len(ev) == 0 {
+ t.Fatal("second tick did not advance the wheel")
+ }
+}