aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/big_six_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 07:11:58 -0400
committerhistoria <[not public]>2026-10-03 07:11:58 -0400
commit0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc (patch)
treeac0af51cc2513b1ddd8a280f360af820d1f097af /internal/casino/big_six_test.go
parent6232d41e5639f9c38e47b2bb65266e343ca26e8f (diff)
downloadthehouseoficarus-0fb83bdf433b9b48240f4ac7a5d4c912f6b808cc.tar.gz
feat: added big 6 wheel to the casino
Diffstat (limited to 'internal/casino/big_six_test.go')
-rw-r--r--internal/casino/big_six_test.go293
1 files changed, 293 insertions, 0 deletions
diff --git a/internal/casino/big_six_test.go b/internal/casino/big_six_test.go
new file mode 100644
index 0000000..cccad77
--- /dev/null
+++ b/internal/casino/big_six_test.go
@@ -0,0 +1,293 @@
+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")
+ }
+}