package casino import ( "math" "math/rand" "strings" "testing" ) func newTestCoinFlip() *coinFlipTable { return newTable(1, TableConfig{ID: "coin_flip", Game: GameCoinFlip}, rand.New(rand.NewSource(42))).game.(*coinFlipTable) } func TestCoinFlipRTP(t *testing.T) { if rtp := coinFlipRTP(); math.Abs(rtp-0.98) > 1e-12 { t.Fatalf("coin flip RTP %.12f, want exactly 0.98", rtp) } if swing := coinFlipExpectedSwing(); math.Abs(swing-(-2)) > 1e-12 { t.Fatalf("expected swing %.12f, want -2", swing) } e := coinFlipHouseEdge(TableConfig{}, "") if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-0.02) > 1e-12 { t.Fatalf("house edge does not match 98%% RTP: %+v", e) } } func TestCoinFlipBlueValues(t *testing.T) { // The blue face must reach 100x, and the removed middle band must never // appear, or the mean (and therefore the RTP) drifts. seen := map[int]bool{} sum := 0 for _, v := range coinFlipBlueValues { if v < 0 || v > coinFlipBlueMax { t.Fatalf("blue value %d out of range", v) } if (v >= 63 && v <= 77) || v == 90 { t.Fatalf("removed blue value %d is still reachable", v) } seen[v] = true sum += v } if !seen[100] { t.Fatal("100x is not reachable on the blue face") } if len(coinFlipBlueValues) != 85 || sum != 3910 { t.Fatalf("blue set size %d sum %d, want 85 and 3910", len(coinFlipBlueValues), sum) } } func TestCoinFlipConfig(t *testing.T) { if (TableConfig{ID: "c", Game: GameCoinFlip, MaxPlayers: 2}).Validate() == nil { t.Fatal("accepted a two-seat coin flip table") } if err := (TableConfig{ID: "c", Game: GameCoinFlip}).Validate(); err != nil { t.Fatal(err) } if got := (TableConfig{ID: "c", Game: GameCoinFlip}).Normalize(); got.MaxPlayers != 1 { t.Fatalf("normalized max players = %d, want 1", got.MaxPlayers) } if (TableConfig{ID: "c", Game: GameCoinFlip, MaxBet: MaxCredits}).Validate() == nil { t.Fatal("accepted a stake whose 100x reserve overflows the economy") } def, ok := tableGameDefFor(GameCoinFlip) if !ok || def.ruleText == nil || def.houseEdge == nil || def.hasRuleBlock == nil || def.hasRuleBlock(TableConfig{}) { t.Fatalf("coin flip registration incomplete: %+v", def.ruleBlockName) } if topic := (TableConfig{ID: "c", Game: GameCoinFlip}).HelpTopic(); topic != "coin flip" { t.Fatalf("help topic %q, want coin flip", topic) } } func TestCoinFlipReserveAndLifecycle(t *testing.T) { c := newTestCoinFlip() // Sitting down must not print a coin graphic. for _, e := range c.join("alice") { if e.Snapshot != nil { t.Fatalf("sitting down printed a coin: %+v", e.Snapshot) } } if events := c.join("bob"); len(events) != 1 || !strings.Contains(events[0].Message, "occupied") { t.Fatalf("unexpected second-seat reply: %+v", events) } w := &testWallet{credits: 1000} events := c.bet("alice", "", 10, w) if c.phase != coinFlipPlaying || len(events) != 1 { t.Fatalf("bet did not start the round: phase %q events %d", c.phase, len(events)) } if w.credits != 0 { t.Fatalf("reserve was not debited: credits %d", w.credits) } if len(w.open) != 1 || w.open[0].Total != 1000 { t.Fatalf("reserved stake = %+v, want 1000", w.open) } swing := c.swing want := (coinFlipReserve + swing) * 10 // The first frame is held back a tick, then prints the red spin strip. first := c.tick() if len(first) == 0 || first[0].Snapshot == nil || first[0].Snapshot.(*CoinFlipSnapshot).Phase != CoinFlipPhaseRedSpin { t.Fatalf("first frame is not the red spin: %+v", first) } for i := 0; i < 128 && c.phase == coinFlipPlaying; i++ { c.tick() } if c.phase != coinFlipIdle { t.Fatalf("round did not settle: phase %q", c.phase) } if w.paid != want { t.Fatalf("paid %d, want %d for swing %d", w.paid, want, swing) } if len(w.open) != 0 { t.Fatalf("stake not resolved: %+v", w.open) } } func TestCoinFlipPrintsEndOnSettledCoin(t *testing.T) { c := newTestCoinFlip() c.join("alice") w := &testWallet{credits: 1000} c.bet("alice", "", 10, w) // The plan must contain a red spin, a blue spin, a results line, and a // final coin frame marked last with the settled background. var sawRed, sawBlue, sawResults, sawSettled, sawSpacer bool for _, p := range c.prints { switch { case p.snap != nil && p.snap.Phase == CoinFlipPhaseRedSpin: sawRed = true case p.snap != nil && p.snap.Phase == CoinFlipPhaseBlueSpin: sawBlue = true case p.snap != nil && p.snap.Phase == CoinFlipPhaseResults: sawResults = true case p.snap != nil && p.snap.Result: sawSettled = true if !p.last { t.Fatal("the settled coin is not the last frame") } case p.msg != "": sawSpacer = true } } if !sawRed || !sawBlue || !sawResults || !sawSettled || !sawSpacer { t.Fatalf("print plan incomplete: red=%v blue=%v results=%v settled=%v spacer=%v", sawRed, sawBlue, sawResults, sawSettled, sawSpacer) } } func TestCoinFlipNeverRepeatsColour(t *testing.T) { // The settled coin must never repeat the colour of the frame before it. c := newTestCoinFlip() for i := 0; i < 5000; i++ { c.red = c.faceValueLocked(CoinFlipSideRed) c.blue = c.faceValueLocked(CoinFlipSideBlue) c.result, c.swing = CoinFlipSideRed, c.red if c.rng.Intn(2) == 0 { c.result, c.swing = CoinFlipSideBlue, c.blue } frames := c.planCoinFramesLocked() if len(frames) < 3 { t.Fatalf("coin planned only %d frames", len(frames)) } if !frames[len(frames)-1].result { t.Fatal("last frame is not the settled coin") } if frames[len(frames)-1].multiplier != c.swing { t.Fatalf("settled multiplier %d, want %d", frames[len(frames)-1].multiplier, c.swing) } for j := 1; j < len(frames); j++ { if frames[j].side == frames[j-1].side { t.Fatalf("frame %d repeats colour %s: %+v", j, frames[j].side, frames) } } } } func TestCoinFlipInsufficientReserve(t *testing.T) { c := newTestCoinFlip() c.join("alice") w := &testWallet{credits: 999} events := c.bet("alice", "", 10, w) if c.phase != coinFlipIdle || len(w.open) != 0 || w.credits != 999 { t.Fatalf("under-reserved bet changed state: phase %q open %d credits %d", c.phase, len(w.open), w.credits) } if len(events) == 0 || !strings.Contains(events[0].Message, "100x") { t.Fatalf("missing reserve explanation: %+v", events) } } func TestCoinFlipFacesAndSwing(t *testing.T) { c := newTestCoinFlip() seenHundred := false for i := 0; i < 200000; i++ { red := c.faceValueLocked(CoinFlipSideRed) blue := c.faceValueLocked(CoinFlipSideBlue) if red > 0 || red < -coinFlipRedMax { t.Fatalf("red face %d out of range", red) } if blue < 0 || blue > coinFlipBlueMax { t.Fatalf("blue face %d out of range", blue) } if blue == 100 { seenHundred = true } } if !seenHundred { t.Fatal("blue 100x never appeared") } } func TestCoinFlipFairCoin(t *testing.T) { // The landing side is an independent fair coin: over many flips the two // faces must be within a few percent of even. c := newTestCoinFlip() reds := 0 const n = 200000 for i := 0; i < n; i++ { if c.rng.Intn(2) == 0 { reds++ } } share := float64(reds) / float64(n) if math.Abs(share-0.5) > 0.01 { t.Fatalf("red landed %.4f of the time, want ~0.5", share) } } func TestCoinFlipSimulatedRTP(t *testing.T) { // End-to-end: the two face draws, the fair landing coin, and the payout // formula must reproduce the 98% design return. c := newTestCoinFlip() const rounds = 200000 staked, returned := 0, 0 for i := 0; i < rounds; i++ { red := c.faceValueLocked(CoinFlipSideRed) blue := c.faceValueLocked(CoinFlipSideBlue) swing := red if c.rng.Intn(2) == 0 { swing = blue } staked += coinFlipReserve returned += coinFlipReserve + swing } rtp := float64(returned) / float64(staked) if math.Abs(rtp-0.98) > 0.005 { t.Fatalf("simulated RTP %.4f is not within 0.5%% of 0.98", rtp) } } func TestCoinFlipRefundAndDisconnect(t *testing.T) { c := newTestCoinFlip() c.join("alice") w := &testWallet{credits: 1000} c.bet("alice", "", 10, w) events := c.refundAll() if len(w.open) != 0 || w.paid != 1000 { t.Fatalf("refund did not restore the reserve: paid %d open %+v", w.paid, w.open) } if len(events) == 0 { t.Fatal("refund produced no event") } if !c.idle() { t.Fatal("refund did not clear the table") } // A disconnect mid-round settles the already-drawn face, never refunds it. c.join("bob") w = &testWallet{credits: 1000} c.bet("bob", "", 10, w) swing := c.swing want := (coinFlipReserve + swing) * 10 c.markDisconnected("bob") if w.paid != want || len(w.open) != 0 { t.Fatalf("disconnect paid %d want %d open %+v", w.paid, want, w.open) } } func TestCoinFlipStakeMultiple(t *testing.T) { c := newTestCoinFlip() if c.stakeMultiple() != coinFlipReserve { t.Fatalf("stake multiple %d, want %d", c.stakeMultiple(), coinFlipReserve) } } func TestCoinFlipMidRoundCommands(t *testing.T) { c := newTestCoinFlip() c.join("alice") w := &testWallet{credits: 100000} c.bet("alice", "", 10, w) // A second bet while the coin is airborne is refused. if events := c.bet("alice", "", 10, w); len(events) == 0 || !strings.Contains(events[0].Message, "air") { t.Fatalf("accepted a mid-round bet: %+v", events) } for i := 0; i < 128 && c.phase == coinFlipPlaying; i++ { c.tick() } if events := c.action("alice", "settle"); len(events) == 0 { t.Fatal("settle after a clean round produced no reply") } }