package room import ( "context" "encoding/json" "fmt" "math/rand" "os" "sync" "testing" "time" "github.com/drjones/quantum-arcade/pkg/fair" "github.com/drjones/quantum-arcade/pkg/fees" "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) // Isolate the payout arithmetic from the fee schedule, which is covered // by its own tests. Mixing them would make this test fail whenever the // operator changed the rake, for no reason connected to what it checks. f.room.Fees = fees.NoFees() 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 payload := <-ch: var snap Snapshot if err := json.Unmarshal(payload, &snap); err != nil { t.Fatal(err) } 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) // The claim under test is that the target pays exactly, not what the // operator deducts afterwards. f.room.Fees = fees.NoFees() 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) } } /* ---------------- abandoned round reconciliation ---------------- */ // The scenario: an instance takes bets, then dies before settling. The stakes // have already left the players' balances. Nobody should be quietly short. func TestAbandonedRoundIsRefunded(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 0 // treat everything as abandoned, for the test id, pk := f.player("a", 100_000) before, _ := f.ledger.Balance(f.ctx, id) f.openBetting() const stake = 10_000 if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { t.Fatal(err) } afterBet, _ := f.ledger.Balance(f.ctx, id) if before-afterBet != stake { t.Fatalf("stake not taken: %d", before-afterBet) } // The instance dies here: the round is never settled. res, err := rc.Run(f.ctx) if err != nil { t.Fatal(err) } if res.BetsRefunded < 1 { t.Fatalf("nothing refunded: %+v", res) } afterRefund, _ := f.ledger.Balance(f.ctx, id) if afterRefund != before { t.Fatalf("balance = %d after refund, want %d (the original stake back)", afterRefund, before) } } // Running twice must not pay twice. func TestReconcileIsIdempotent(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 0 id, pk := f.player("a", 100_000) before, _ := f.ledger.Balance(f.ctx, id) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil { t.Fatal(err) } if _, err := rc.Run(f.ctx); err != nil { t.Fatal(err) } afterFirst, _ := f.ledger.Balance(f.ctx, id) for i := 0; i < 3; i++ { if _, err := rc.Run(f.ctx); err != nil { t.Fatal(err) } } afterRepeats, _ := f.ledger.Balance(f.ctx, id) if afterRepeats != afterFirst { t.Fatalf("repeated reconciliation paid again: %d -> %d", afterFirst, afterRepeats) } if afterFirst != before { t.Fatalf("refund was not exactly the stake: %d, want %d", afterFirst, before) } } // Concurrent reconcilers, as two instances would be, must still refund once. func TestConcurrentReconcilersRefundOnce(t *testing.T) { f := newFixture(t) rc1 := NewReconciler(f.room.pool, f.ledger) rc2 := NewReconciler(f.room.pool, f.ledger) rc1.Stale, rc2.Stale = 0, 0 id, pk := f.player("a", 100_000) before, _ := f.ledger.Balance(f.ctx, id) f.openBetting() if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil { t.Fatal(err) } var wg sync.WaitGroup for _, rc := range []*Reconciler{rc1, rc2, rc1, rc2} { wg.Add(1) go func(rc *Reconciler) { defer wg.Done() _, _ = rc.Run(f.ctx) }(rc) } wg.Wait() after, _ := f.ledger.Balance(f.ctx, id) if after != before { t.Fatalf("concurrent reconcilers refunded %d, want exactly the stake (%d)", after-before+10_000, 10_000) } } // A round that settled normally must never be refunded on top of its payout. func TestSettledRoundIsNotRefunded(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 0 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() if err := f.room.settle(f.ctx); err != nil { t.Fatal(err) } afterSettle, _ := f.ledger.Balance(f.ctx, id) if _, err := rc.Run(f.ctx); err != nil { t.Fatal(err) } afterReconcile, _ := f.ledger.Balance(f.ctx, id) if afterReconcile != afterSettle { t.Fatalf("a settled round was refunded: %d -> %d", afterSettle, afterReconcile) } } // A round still in flight must be left alone. func TestLiveRoundIsNotRefunded(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 2 * time.Minute // the production value 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) } afterBet, _ := f.ledger.Balance(f.ctx, id) res, err := rc.Run(f.ctx) if err != nil { t.Fatal(err) } if res.RoundsRefunded != 0 { t.Fatalf("a live round was refunded out from under its players: %+v", res) } after, _ := f.ledger.Balance(f.ctx, id) if after != afterBet { t.Fatalf("balance changed on a live round: %d -> %d", afterBet, after) } } // The books must still balance after a refund. func TestBooksBalanceAfterRefund(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 0 f.openBetting() 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) } } if _, err := rc.Run(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 refunds: %d", total) } } /* ---------------- operating fees ---------------- */ // A winning player must receive the payout minus the disclosed fee, and the // deduction must appear as its own ledger entry rather than being folded // silently into a smaller win. func TestFeesAreDeductedAndItemised(t *testing.T) { f := newFixture(t) house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 50_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { t.Fatal(err) } id, pk := f.player("a", 10_000_000) f.room.Fees = fees.Schedule{RakeBP: 100, RoundToMsat: 1_000, MinPayoutMsat: 1_000} f.openBetting() const stake = 1_000_000 if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2))) at, err := f.room.CashOut(id) if err != nil { t.Fatal(err) } 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) gross := stake * int64(at) / int64(fixed.One) want := f.room.Fees.Apply(gross) if got := after - before; got != want.NetMsat { t.Fatalf("player received %d, want %d net of fees (gross %d)", got, want.NetMsat, gross) } // The history must show the win and the charge separately. entries, err := f.ledger.History(f.ctx, id, 10) if err != nil { t.Fatal(err) } var sawPayout, sawFee bool for _, e := range entries { if e.Kind == "payout" && e.AmountMsat == gross { sawPayout = true } if e.Kind == "operating_fee" && e.AmountMsat == -want.HouseMsat() { sawFee = true } } if !sawPayout { t.Error("history does not show the full payout") } if !sawFee { t.Error("history does not itemise the operating fee") } } // The books must still balance once fees are being taken. func TestBooksBalanceWithFees(t *testing.T) { f := newFixture(t) house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 50_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { t.Fatal(err) } f.room.Fees = fees.DefaultSchedule() f.openBetting() var ids []int64 for i := 0; i < 5; i++ { id, pk := f.player(fmt.Sprintf("p%d", i), 5_000_000) if err := f.room.PlaceBet(f.ctx, id, pk, "p", 500_000, 0); err != nil { t.Fatal(err) } ids = append(ids, id) } f.startRun() f.forceCrashPoint(100) f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(3))) for i, id := range ids { if i%2 == 0 { if _, err := f.room.CashOut(id); err != nil { t.Fatal(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 with fees enabled: %d", total) } } // With fees disabled the player must receive the full payout. func TestNoFeesPaysFullAmount(t *testing.T) { f := newFixture(t) house, _ := f.ledger.AccountByName(f.ctx, "house_pot") hp, _ := f.player("housefund", 50_000_000) if _, err := f.ledger.Transfer(f.ctx, hp, house, 50_000_000); err != nil { t.Fatal(err) } id, pk := f.player("a", 10_000_000) f.room.Fees = fees.NoFees() f.openBetting() const stake = 1_000_000 if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil { t.Fatal(err) } f.startRun() f.forceCrashPoint(100) f.advanceTo(sim.TicksToMultiplier(fixed.FromInt(2))) at, _ := f.room.CashOut(id) 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) gross := stake * int64(at) / int64(fixed.One) if got := after - before; got != gross { t.Fatalf("player received %d with fees disabled, want the full %d", got, gross) } } // Rounds nobody joined must also be closed, or the operator's unresolved-round // signal fills with noise and stops meaning anything. func TestEmptyAbandonedRoundsAreClosed(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 0 // Open rounds and never settle them; nobody bets. for i := 0; i < 3; i++ { f.openBetting() } res, err := rc.Run(f.ctx) if err != nil { t.Fatal(err) } if res.EmptyRoundsClosed < 3 { t.Fatalf("closed %d empty rounds, want at least 3", res.EmptyRoundsClosed) } var stillOpen int if err := f.room.pool.QueryRow(f.ctx, `SELECT count(*) FROM rounds WHERE settled_at IS NULL AND voided_at IS NULL`).Scan(&stillOpen); err != nil { t.Fatal(err) } if stillOpen != 0 { t.Fatalf("%d rounds remain unresolved after reconciliation", stillOpen) } } // Closing empty rounds must not touch rounds that have players in them. func TestEmptyRoundClosureSpareRoundsWithBets(t *testing.T) { f := newFixture(t) rc := NewReconciler(f.room.pool, f.ledger) rc.Stale = 2 * time.Minute // nothing is stale yet 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) } roundID := f.room.roundID if _, err := rc.Run(f.ctx); err != nil { t.Fatal(err) } var voided *time.Time if err := f.room.pool.QueryRow(f.ctx, `SELECT voided_at FROM rounds WHERE id = $1`, roundID).Scan(&voided); err != nil { t.Fatal(err) } if voided != nil { t.Fatal("a live round with a player in it was voided") } }