package casino import ( "math" "math/big" "math/rand" "strings" "testing" ) func newBigSixTest() *bigSixTable { return newBigSixTable(1, TableConfig{ID: "wheel", Game: GameBigSix}, rand.New(rand.NewSource(123))) } func forceBigSix(t *bigSixTable, spot string) []Event { for i, s := range bigSixWheel { if s == spot { t.winningIndex = i return t.settle() } } panic("unknown test symbol") } func TestBigSixRTPAndRegistry(t *testing.T) { cfg := TableConfig{ID: "wheel", Game: GameBigSix} if err := cfg.Validate(); err != nil { t.Fatal(err) } if cfg.Normalize().BettingTicks != 25 || cfg.HelpTopic() != "big6" || newTable(1, cfg, newTestRand()) == nil { t.Fatal("registration") } counts := map[string]int{} for _, s := range bigSixWheel { counts[s]++ } if len(bigSixWheel) != 51 || counts["DOUBLE"] != 1 { t.Fatal(counts) } for _, s := range BigSixSpots() { if counts[s.Label] != s.Count { t.Fatal(s) } if s.Pays == 0 { continue } rtp := float64(s.Count*(s.Pays+1)) / float64(len(bigSixWheel)-2*counts["DOUBLE"]) if rtp < .978 || rtp > .985 || math.Abs(rtp-48.0/49) > 1e-12 { t.Fatal(s, rtp) } if e := cfg.HouseEdge(s.Label); len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-(1-rtp)) > 1e-12 { t.Fatal(e) } } if len(cfg.HouseEdge("DOUBLE").Parts) != 0 { t.Fatal("DOUBLE is not a target") } } func TestBigSixMultipleBetsAndDouble(t *testing.T) { table := newBigSixTest() table.join("a") w := &recordingWallet{testWallet: testWallet{credits: 100}} table.bet("a", "$1", 10, w) table.bet("a", "sword", 2, w) table.bet("a", "1", 3, w) if table.phase != phaseWaiting || len(w.open) != 3 { t.Fatal("bet started spin or lost wager") } table.action("a", "spin") if table.phase != phasePlaying { t.Fatal("spin") } forceBigSix(table, "DOUBLE") forceBigSix(table, "DOUBLE") if table.multiplier.String() != "4" || w.paid != 0 || len(w.open) != 3 { t.Fatal("DOUBLE must ride all bets without cashing out") } table.bet("a", "2", 1, w) table.action("a", "unbet") if len(w.open) != 3 || len(table.players.get("a").bets) != 3 { t.Fatal("bet/unbet accepted during respin") } events := forceBigSix(table, "1") if w.paid != 104 || len(w.open) != 0 || len(w.payStakes) != 3 { t.Fatal("wrong payout/ledger", w) } for _, e := range events { if len(e.Settlement) > 0 { if len(e.Settlement) != 3 || e.Settlement[0].Stake != 10 || e.Settlement[0].Returned != 80 || e.Settlement[0].Odds != "7:1" { t.Fatal(e.Settlement) } } } if table.multiplier.String() != "1" || table.phase != phaseWaiting { t.Fatal("not reset") } } func TestBigSixUncappedDoubleAndSafeOverflow(t *testing.T) { table := newBigSixTest() table.join("a") w := &testWallet{credits: 10} table.bet("a", "1", 1, w) table.action("a", "spin") for i := 0; i < 130; i++ { forceBigSix(table, "DOUBLE") } if table.multiplier.Cmp(new(big.Int).Lsh(big.NewInt(1), 130)) != 0 { t.Fatal("DOUBLE capped or wrapped") } events := forceBigSix(table, "1") if w.paid != 0 || len(w.open) != 1 { t.Fatal("overflow must not pay or consume pending stake") } for _, e := range events { if len(e.Settlement) > 0 { t.Fatal("announced unbanked payout") } } table.refundAll() if len(w.open) != 0 { t.Fatal("recovery did not refund") } } func TestBigSixMultiplayerClockWithoutBets(t *testing.T) { table := newBigSixTest() table.join("a") table.join("b") if table.phase != phaseBetting || table.window.timer.remaining() != 25 { t.Fatal("join must open betting") } table.action("a", "spin") for i := 0; i < 24; i++ { table.tick() if table.phase != phaseBetting { t.Fatal("early spin", i) } } table.tick() if table.phase != phasePlaying { t.Fatal("empty round did not spin") } forceBigSix(table, "2") if table.phase != phaseBetting || table.window.timer.remaining() != 25 { t.Fatal("next window") } } func TestBigSixAnimationGeometryAndTiming(t *testing.T) { sawLong, sawVariation := false, false length := 0 for round := 0; round < 250; round++ { table := newBigSixTest() table.startSpin() frames := append([]bigSixFrame(nil), table.frames...) if length != 0 && length != len(frames) { sawVariation = true } length = len(frames) fast := 0 prevStep, prevDelay := 10, 1 for i, f := range frames { if f.delay == 1 { fast++ } if f.delay >= 8 { sawLong = true } if f.move > prevStep || f.delay < prevDelay { t.Fatalf("not slowing: %+v", frames) } if f.delay == 1 && (f.move < 4 || f.move > 8) || f.delay == 2 && (f.move < 2 || f.move > 4) || f.delay == 3 && (f.move < 1 || f.move > 2) { t.Fatal(frames) } // Only the very last click may take a long pause; everything else // is capped so the wheel never crawls. if i < len(frames)-1 && f.delay > 4 { t.Fatalf("wait uncapped before the last click: %+v", frames) } prevStep, prevDelay = f.move, f.delay } if fast < 3 || fast > 6 { t.Fatal("fast count", fast) } // The final approach is all single-space clicks, with an optional // stationary click at the very end. approach := 0 for i := len(frames) - 1; i >= 0 && frames[i].move <= 1; i-- { approach++ } if approach < 3 { t.Fatalf("wheel must click one space at least 3 times at rest: %+v", frames) } if frames[len(frames)-1].move > 1 { t.Fatalf("final click must stay or advance one space: %+v", frames) } winner := table.winningIndex position := table.position for i, f := range frames { for wait := 1; wait < f.delay; wait++ { if e := table.tick(); len(e) != 0 { t.Fatal("early frame") } } events := table.tick() position = (position + f.move) % len(bigSixWheel) var s *BigSixSnapshot for _, e := range events { if v, ok := e.Snapshot.(*BigSixSnapshot); ok { s = v break } } if s == nil { t.Fatal("missing frame") } count := 11 if i == len(frames)-1 { count = 9 if position != winner { t.Fatal("outcome teleport") } } if len(s.Pockets) != count { t.Fatal("strip width", s) } for j, p := range s.Pockets { if p != bigSixWheel[(position+j-count/2+51)%51] { t.Fatal("invented neighbor") } } } } if !sawLong || !sawVariation { t.Fatal("no suspense variation") } } func TestBigSixUnbetAndDisconnect(t *testing.T) { for _, spinning := range []bool{false, true} { table := newBigSixTest() table.join("a") w := &testWallet{credits: 100} table.bet("a", "1", 10, w) table.bet("a", "2", 20, w) if spinning { table.action("a", "spin") forceBigSix(table, "DOUBLE") } else { table.action("a", "unbet") if w.paid != 30 || len(w.open) != 0 || table.players.get("a").HasBet { t.Fatal("unbet") } table.bet("a", "1", 10, w) } table.markDisconnected("a") if len(w.open) != 0 || table.players.count() != 0 || table.phase != phaseWaiting { t.Fatal("disconnect cleanup") } if spinning && w.paid != 0 { t.Fatal("selective refund exploit") } } } func TestBigSixSettlementFailureAndDeparture(t *testing.T) { table := newBigSixTest() table.join("a") w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} table.bet("a", "1", 10, w) table.action("a", "spin") events := forceBigSix(table, "1") if len(w.open) != 1 { t.Fatal("failed payout consumed stake") } found := false for _, e := range events { if strings.Contains(e.Message, "pending recovery") { found = true } if len(e.Settlement) > 0 { t.Fatal("fake receipt") } } if !found { t.Fatal("missing error") } table.refundAll() table.bet("a", "1", 1, &testWallet{credits: 10}) table.action("a", "leave_after") table.action("a", "spin") events = forceBigSix(table, "2") if table.players.count() != 0 { t.Fatal("queued departure") } if s, ok := events[0].Snapshot.(*BigSixSnapshot); !ok || len(s.Players) != 1 || s.Players[0] != "a" { t.Fatal("departing player lost result visibility") } } func TestBigSixPartialTargets(t *testing.T) { for _, tc := range []struct { in string want string ok bool }{ {"1", "1", true}, {"10", "10", true}, {"$20", "20", true}, {"sw", "SWORD", true}, {"SWO", "SWORD", true}, {"sword", "SWORD", true}, {"sh", "SHIELD", true}, {"shi", "SHIELD", true}, {"shield", "SHIELD", true}, {"s", "", false}, // SWORD or SHIELD {"do", "", false}, // DOUBLE is automatic, not a wager {"double", "", false}, // and its full name is not a wager either {"", "", false}, {"99", "", false}, } { got, ok := bigSixResolveTarget(tc.in) if got != tc.want || ok != tc.ok { t.Fatalf("bigSixResolveTarget(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.ok) } } // A partial target must behave exactly like the full label at bet time, // both in the stored wager and in the house-edge reference. cfg := TableConfig{Game: GameBigSix} if e := cfg.HouseEdge("sw"); len(e.Parts) != 1 || e.Parts[0].Loss != 1.0/49 { t.Fatalf("partial house edge: %+v", e) } table := newBigSixTest() table.join("a") w := &recordingWallet{testWallet: testWallet{credits: 100}} table.bet("a", "sw", 5, w) table.bet("a", "sh", 7, w) bets := table.players.get("a").bets if len(w.open) != 2 || len(bets) != 2 || bets[0].spot != "SWORD" || bets[1].spot != "SHIELD" { t.Fatalf("partial targets not canonicalized: %+v", bets) } if bigSixPayout("s") != 0 || bigSixPayout("sw") != 48 || bigSixPayout("sh") != 48 { t.Fatal("partial payout") } }