aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/coin_flip_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-09 17:05:54 -0400
committerhistoria <[not public]>2026-10-09 17:05:54 -0400
commitced77d7c89129da6ba4001ab8e3dad265aa41396 (patch)
tree2acfd429e5b4a83121565bf5f1bbc122228e9d8a /internal/casino/coin_flip_test.go
parent6321c0a6b63061ac57a982c9ec5f93c1f731ae65 (diff)
downloadthehouseoficarus-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.go296
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")
+ }
+}