package room import ( "context" "fmt" "math/rand" "os" "sync" "testing" "time" "github.com/drjones/quantum-arcade/pkg/fair" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/sim" "github.com/jackc/pgx/v5/pgxpool" ) // These are internal tests so the state machine can be driven a step at a time // instead of waiting on wall-clock timers. var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63()) func testPool(t *testing.T) *pgxpool.Pool { t.Helper() dsn := os.Getenv("ARCADE_TEST_DSN") if dsn == "" { dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade" } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Skipf("no database available: %v", err) } if err := pool.Ping(context.Background()); err != nil { t.Skipf("no database available: %v", err) } return pool } type fixture struct { t *testing.T room *Room ledger *ledger.Ledger ctx context.Context } func newFixture(t *testing.T) *fixture { t.Helper() pool := testPool(t) l := ledger.New(pool) return &fixture{ t: t, room: New("rocket", pool, l), ledger: l, ctx: context.Background(), } } // player creates a funded account and returns its id and public key. func (f *fixture) player(label string, fundMsat int64) (int64, []byte) { f.t.Helper() pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label)) id, err := f.ledger.EnsurePlayer(f.ctx, pk) if err != nil { f.t.Fatal(err) } if fundMsat > 0 { if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil { f.t.Fatal(err) } } return id, pk } // openBetting drives the room into an open betting window. func (f *fixture) openBetting() { f.t.Helper() if err := f.room.openRound(f.ctx); err != nil { f.t.Fatal(err) } } // startRun locks the round and begins the climb. func (f *fixture) startRun() { f.t.Helper() f.room.mu.Lock() f.room.state = StateLocked f.room.mu.Unlock() if err := f.room.startRunning(f.ctx); err != nil { f.t.Fatal(err) } } // forceCrashPoint pins the round's crash point so cash-out tests do not depend // on a random draw. A genuine 1.00x crash is an instant bust where nobody can // cash out, which is correct behaviour but useless for testing the cash-out // path. func (f *fixture) forceCrashPoint(multiplier int64) { f.t.Helper() f.room.mu.Lock() f.room.crashPoint = fixed.FromInt(multiplier) f.room.mu.Unlock() } // advanceTo moves the round to a specific tick without settling. func (f *fixture) advanceTo(tick int) { f.t.Helper() f.room.mu.Lock() f.room.tick = tick f.room.mu.Unlock() } /* ---------------- lifecycle ---------------- */ func TestNewRoomStartsSettled(t *testing.T) { f := newFixture(t) if got := f.room.Snapshot().State; got != StateSettled { t.Fatalf("new room state = %q, want %q", got, StateSettled) } } func TestOpenRoundCommitsBeforeBetting(t *testing.T) { f := newFixture(t) f.openBetting() snap := f.room.Snapshot() if snap.State != StateBetting { t.Fatalf("state = %q, want betting_open", snap.State) } if snap.Commitment == "" { t.Fatal("no commitment published when betting opened") } // The seed must not leak while bets are still being taken. if snap.ServerSeed != "" { t.Fatal("server seed exposed during the betting window") } if snap.CrashPoint != "" { t.Fatal("crash point exposed during the betting window") } } func TestSeedRevealedOnlyAfterSettlement(t *testing.T) { f := newFixture(t) f.openBetting() f.startRun() if snap := f.room.Snapshot(); snap.ServerSeed != "" { t.Fatal("seed revealed while the round was running") } if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } snap := f.room.Snapshot() if snap.ServerSeed == "" { t.Fatal("seed not revealed after settlement") } if snap.CrashPoint == "" { t.Fatal("crash point not revealed after settlement") } } func TestEachRoundGetsAFreshSeed(t *testing.T) { f := newFixture(t) seen := map[string]bool{} for i := 0; i < 20; i++ { f.openBetting() c := f.room.Snapshot().Commitment if seen[c] { t.Fatalf("commitment reused on round %d", i) } seen[c] = true } } func TestNonceAdvancesPerRound(t *testing.T) { f := newFixture(t) f.openBetting() first := f.room.nonce f.openBetting() if f.room.nonce != first+1 { t.Fatalf("nonce went %d -> %d, want +1", first, f.room.nonce) } } /* ---------------- betting ---------------- */ func TestPlaceBetDebitsStakeImmediately(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() before, _ := f.ledger.Balance(f.ctx, id) if err := f.room.PlaceBet(f.ctx, id, pk, "a", 3_000, 0); err != nil { t.Fatal(err) } after, _ := f.ledger.Balance(f.ctx, id) if before-after != 3_000 { t.Fatalf("balance moved by %d, want 3000", before-after) } } func TestCannotBetOutsideBettingWindow(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) // Room starts settled. if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("bet accepted while settled") } f.openBetting() f.startRun() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("bet accepted while running") } } func TestCannotBetTwiceInOneRound(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err == nil { t.Fatal("second bet in the same round was accepted") } } func TestCannotBetMoreThanBalance(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 1_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 50_000, 0); err == nil { t.Fatal("bet larger than balance was accepted") } // And nothing was taken. if bal, _ := f.ledger.Balance(f.ctx, id); bal != 1_000 { t.Fatalf("balance = %d after failed bet, want 1000", bal) } } func TestNonPositiveStakesRejected(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() for _, stake := range []int64{0, -1, -5_000} { if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err == nil { t.Fatalf("stake %d was accepted", stake) } } } /* ---------------- cash out ---------------- */ func TestCashOutOnlyWhileRunning(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } if _, err := f.room.CashOut(id); err == nil { t.Fatal("cash out accepted during betting") } f.startRun() f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatalf("cash out rejected while running: %v", err) } } func TestCannotCashOutTwice(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0) f.startRun() f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatal(err) } if _, err := f.room.CashOut(id); err == nil { t.Fatal("second cash out was accepted") } } func TestCannotCashOutWithoutABet(t *testing.T) { f := newFixture(t) id, _ := f.player("a", 10_000) f.openBetting() f.startRun() f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err == nil { t.Fatal("cash out accepted with no bet placed") } } // Past the crash point there is nothing left to cash out. func TestCannotCashOutAfterTheCrash(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 10_000) f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0) f.startRun() // Jump past the crash point without letting the loop settle. f.room.mu.Lock() target := sim.TicksToMultiplier(f.room.crashPoint) f.room.mu.Unlock() f.advanceTo(target + 5) if _, err := f.room.CashOut(id); err == nil { t.Fatal("cash out accepted after the crash point") } } /* ---------------- settlement ---------------- */ func TestCashedOutPlayerIsPaid(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) house, err := f.ledger.AccountByName(f.ctx, "house_pot") if err != nil { t.Fatal(err) } // Fund the house so it can cover the payout. hp, _ := f.player("housefund", 1_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 1_000_000); err != nil { t.Fatal(err) } f.openBetting() const stake = 10_000 if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { t.Fatal(err) } f.startRun() // Advance a little so the multiplier is meaningfully above 1.0, but stay // below the crash point. f.room.mu.Lock() crashTick := sim.TicksToMultiplier(f.room.crashPoint) f.room.mu.Unlock() if crashTick < 2 { t.Skip("crash point too low for this test; rerun") } f.advanceTo(crashTick - 1) at, err := f.room.CashOut(id) if err != nil { t.Fatal(err) } beforeSettle, _ := f.ledger.Balance(f.ctx, id) if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } afterSettle, _ := f.ledger.Balance(f.ctx, id) want := stake * int64(at) / int64(fixed.One) if afterSettle-beforeSettle != want { t.Fatalf("payout = %d, want %d (cashed out at %v)", afterSettle-beforeSettle, want, at) } } func TestPlayerWhoDidNotCashOutGetsNothing(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil { t.Fatal(err) } f.startRun() before, _ := f.ledger.Balance(f.ctx, id) if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } after, _ := f.ledger.Balance(f.ctx, id) if after != before { t.Fatalf("balance changed by %d for a player who never cashed out", after-before) } } // The books must balance across a full round with mixed outcomes. func TestBooksBalanceAcrossAFullRound(t *testing.T) { f := newFixture(t) house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 10_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 10_000_000); err != nil { t.Fatal(err) } f.openBetting() var ids []int64 for i := 0; i < 5; i++ { id, pk := f.player(fmt.Sprintf("p%d", i), 100_000) if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000, 0); err != nil { t.Fatal(err) } ids = append(ids, id) } f.startRun() f.room.mu.Lock() crashTick := sim.TicksToMultiplier(f.room.crashPoint) f.room.mu.Unlock() if crashTick > 2 { f.advanceTo(crashTick - 1) // Half cash out, half ride it in. for i, id := range ids { if i%2 == 0 { if _, err := f.room.CashOut(id); err != nil { t.Fatalf("cash out %d: %v", i, err) } } } } if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } total, err := f.ledger.ConservationCheck(f.ctx) if err != nil { t.Fatal(err) } if total != 0 { t.Fatalf("books do not balance after settlement: %d", total) } } /* ---------------- fairness wiring ---------------- */ // The crash point must follow from the committed seed and the participant set, // which is what makes the published proof meaningful. func TestCrashPointDerivesFromCommittedSeedAndPlayers(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, 0); err != nil { t.Fatal(err) } f.startRun() f.room.mu.RLock() seed := f.room.serverSeed nonce := f.room.nonce order := append([][]byte(nil), f.room.order...) actual := f.room.crashPoint f.room.mu.RUnlock() expected := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed(order), nonce)) if actual != expected { t.Fatalf("crash point %v does not follow from the published inputs (want %v)", actual, expected) } } func TestAddingAPlayerChangesTheOutcome(t *testing.T) { f := newFixture(t) _, pkA := f.player("a", 100_000) _, pkB := f.player("b", 100_000) seed := fair.NewServerSeed() one := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed([][]byte{pkA}), 1)) two := sim.CrashPoint(fair.RoundSeed(seed, fair.ClientSeed([][]byte{pkA, pkB}), 1)) if one == two { t.Fatal("a second participant did not affect the outcome") } } /* ---------------- broadcast ---------------- */ func TestSubscriberReceivesUpdates(t *testing.T) { f := newFixture(t) ch, unsubscribe := f.room.Subscribe() defer unsubscribe() f.openBetting() select { case snap := <-ch: if snap.State != StateBetting { t.Fatalf("received state %q, want betting_open", snap.State) } case <-time.After(2 * time.Second): t.Fatal("subscriber received no update") } } // A phone that stops reading must not stall the round for everyone else. func TestSlowSubscriberDoesNotBlockTheRoom(t *testing.T) { f := newFixture(t) _, unsubscribe := f.room.Subscribe() // never drained defer unsubscribe() done := make(chan struct{}) go func() { for i := 0; i < 200; i++ { f.room.broadcast() } close(done) }() select { case <-done: case <-time.After(3 * time.Second): t.Fatal("broadcast blocked on a subscriber that stopped reading") } } func TestUnsubscribeStopsDelivery(t *testing.T) { f := newFixture(t) ch, unsubscribe := f.room.Subscribe() unsubscribe() // The channel is closed, so a receive returns immediately with ok == false. select { case _, ok := <-ch: if ok { t.Fatal("received a value after unsubscribing") } case <-time.After(time.Second): t.Fatal("channel was not closed by unsubscribe") } } /* ---------------- concurrency ---------------- */ // Many players betting at once must all be recorded, with no lost updates and // no double-charging. func TestConcurrentBetsAreAllRecorded(t *testing.T) { f := newFixture(t) f.openBetting() const players = 12 type acct struct { id int64 pk []byte } accts := make([]acct, players) for i := range accts { id, pk := f.player(fmt.Sprintf("c%d", i), 100_000) accts[i] = acct{id, pk} } var wg sync.WaitGroup errs := make([]error, players) for i, a := range accts { wg.Add(1) go func(i int, a acct) { defer wg.Done() errs[i] = f.room.PlaceBet(f.ctx, a.id, a.pk, "c", 5_000, 0) }(i, a) } wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("player %d could not bet: %v", i, err) } } if got := len(f.room.Snapshot().Players); got != players { t.Fatalf("%d players in the round, want %d", got, players) } for _, a := range accts { bal, _ := f.ledger.Balance(f.ctx, a.id) if bal != 95_000 { t.Fatalf("account %d balance = %d, want 95000", a.id, bal) } } } // Concurrent cash-outs by the same player must yield exactly one success. func TestConcurrentCashOutsYieldOne(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000, 0) f.startRun() f.forceCrashPoint(100) const attempts = 10 var wg sync.WaitGroup results := make([]error, attempts) for i := 0; i < attempts; i++ { wg.Add(1) go func(i int) { defer wg.Done() _, results[i] = f.room.CashOut(id) }(i) } wg.Wait() successes := 0 for _, err := range results { if err == nil { successes++ } } if successes != 1 { t.Fatalf("%d concurrent cash-outs succeeded, want 1", successes) } } /* ---------------- snapshot ---------------- */ func TestSnapshotReportsCashOutMultiplier(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000, 0) f.startRun() f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatal(err) } snap := f.room.Snapshot() if len(snap.Players) != 1 { t.Fatalf("%d players in snapshot, want 1", len(snap.Players)) } if snap.Players[0].CashedOut == "" { t.Fatal("snapshot does not show the cash-out") } if snap.Players[0].Nickname != "nick" { t.Fatalf("nickname = %q, want %q", snap.Players[0].Nickname, "nick") } } func TestMultiplierStartsAtOneEachRound(t *testing.T) { f := newFixture(t) f.openBetting() if got := f.room.Snapshot().Multiplier; got != fixed.One.String() { t.Fatalf("multiplier at round open = %s, want %s", got, fixed.One.String()) } } /* ---------------- auto cash-out ---------------- */ func TestAutoCashOutFiresAtExactlyTheTarget(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() target := fixed.FromInt(3) if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) // well above the target, so it must fire // Advance to the tick that reaches the target. f.advanceTo(sim.TicksToMultiplier(target)) f.room.mu.Lock() f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) got := f.room.bets[id].CashedOutAt f.room.mu.Unlock() if got != target { t.Fatalf("auto cash-out closed at %v, want exactly %v", got, target) } } func TestAutoCashOutDoesNotFireBelowTarget(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() target := fixed.FromInt(5) if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) // One tick short of the target. f.advanceTo(sim.TicksToMultiplier(target) - 1) f.room.mu.Lock() f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) got := f.room.bets[id].CashedOutAt f.room.mu.Unlock() if got != 0 { t.Fatalf("auto cash-out fired early at %v", got) } } // A target above the crash point must never pay: the round ends first. func TestAutoCashOutAboveCrashPointNeverFires(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(3) // crashes well before the target f.advanceTo(sim.RoundTicks - 1) f.room.mu.Lock() f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) got := f.room.bets[id].CashedOutAt f.room.mu.Unlock() if got != 0 { t.Fatalf("auto cash-out paid %v on a target above the crash point", got) } } // A target exactly at the crash point is a win, not a loss. func TestAutoCashOutAtExactlyTheCrashPointPays(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() target := fixed.FromInt(4) if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, target); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(4) f.advanceTo(sim.TicksToMultiplier(target)) f.room.mu.Lock() f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) got := f.room.bets[id].CashedOutAt f.room.mu.Unlock() if got != target { t.Fatalf("target equal to the crash point paid %v, want %v", got, target) } } func TestAutoCashOutTargetMustExceedOne(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() for _, target := range []fixed.F{fixed.One, fixed.One / 2} { if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000, target); err == nil { t.Fatalf("target %v was accepted", target) } } // And the stake was never taken. if bal, _ := f.ledger.Balance(f.ctx, id); bal != 100_000 { t.Fatalf("balance = %d after rejected bets, want 100000", bal) } } // An auto cash-out must pay the target exactly, not the tick's multiplier. func TestAutoCashOutPaysTheTargetExactly(t *testing.T) { f := newFixture(t) house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 5_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil { t.Fatal(err) } id, pk := f.player("a", 100_000) f.openBetting() const stake = 10_000 target := fixed.FromInt(3) if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, target); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) f.advanceTo(sim.TicksToMultiplier(target)) f.room.mu.Lock() f.room.triggerAutoCashOutsLocked(sim.MultiplierAt(f.room.tick)) f.room.mu.Unlock() before, _ := f.ledger.Balance(f.ctx, id) if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } after, _ := f.ledger.Balance(f.ctx, id) if got, want := after-before, int64(stake*3); got != want { t.Fatalf("paid %d, want exactly %d (3.00x of %d)", got, want, stake) } } // A manual cash-out still works when an auto target is set but not yet reached. func TestManualCashOutOverridesAPendingTarget(t *testing.T) { f := newFixture(t) id, pk := f.player("a", 100_000) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, fixed.FromInt(50)); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) at, err := f.room.CashOut(id) if err != nil { t.Fatalf("manual cash-out rejected: %v", err) } if at >= fixed.FromInt(50) { t.Fatalf("manual cash-out returned %v, expected the current multiplier", at) } }