Measured, then fixed, the three things that made a crowd impossible. Ledger: Post issued three round trips per posting, so settlement scaled in network latency rather than work. It is now two statements regardless of leg count — settling 1000 winners went 844ms to 220ms. The lock and the balance read must stay separate statements: a single statement, even one whose CTE does FOR UPDATE, evaluates against a snapshot taken before the locks are held, so concurrent transactions read stale balances and money disappears. The conservation tests caught exactly that. Broadcast: every connection marshalled its own copy, ~355us each. At any real crowd that exceeds the tick interval by orders of magnitude. Frames are now serialised once per broadcast and shared. Feed: the player list is capped at 24 and carries no public keys, and running rounds broadcast at 5Hz instead of 60Hz. Clients compute the multiplier locally from the round start time, which the deterministic curve makes exact. Frame size fell from 3.6KB to 1.8KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
817 lines
20 KiB
Go
817 lines
20 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/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 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)
|
|
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)
|
|
}
|
|
}
|