diff options
| author | historia <[not public]> | 2026-10-09 17:05:54 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-09 17:05:54 -0400 |
| commit | ced77d7c89129da6ba4001ab8e3dad265aa41396 (patch) | |
| tree | 2acfd429e5b4a83121565bf5f1bbc122228e9d8a /internal/casino/coin_flip_test.go | |
| parent | 6321c0a6b63061ac57a982c9ec5f93c1f731ae65 (diff) | |
| download | thehouseoficarus-ced77d7c89129da6ba4001ab8e3dad265aa41396.tar.gz | |
feat: new casino game: coin flip
Diffstat (limited to 'internal/casino/coin_flip_test.go')
| -rw-r--r-- | internal/casino/coin_flip_test.go | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/internal/casino/coin_flip_test.go b/internal/casino/coin_flip_test.go new file mode 100644 index 0000000..41430a5 --- /dev/null +++ b/internal/casino/coin_flip_test.go @@ -0,0 +1,296 @@ +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") + } +} |
