Files
casino/pkg/room/room_test.go
drjones 8af6fd585e feat(tournament): scheduled events with prize pools
Entry fees collect into a real ledger account rather than a number in a
row, so tournament money obeys the same double-entry invariants as
everything else and every movement is explained by a posting.

Settlement distributes the entire pool: dividing a pool across percentage
shares leaves a remainder, and dropping it would destroy money and break
conservation, so it goes to first place. Settlement claims the tournament
before paying, so two instances cannot both pay out. Cancellation refunds
every entrant and asserts the pool empties exactly.

18 tests including concurrent entry, concurrent settlement, unfunded
entry taking no seat, and books balancing after payout.

Removes an append-only trigger that had been over-applied to entry rows.
An entry is a seat reservation, not a financial record: a seat claimed
but unpaid must be releasable so the player can retry once funded. The
money side stays immutable because it is a ledger posting.

The journey test now derives the expected payout from the published fee
schedule instead of hardcoding it, so it keeps checking something real if
the rake changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:54:18 +00:00

1205 lines
31 KiB
Go

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")
}
}