At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the rarest and most valuable outcome silently became an instant 1.00x loss. At u=2 it produced a 2.1-billion-times payout the house could never cover, which would have left settlement failing and the player unpaid. The crash point is now capped at the largest multiplier the curve can express, which is unreachable anyway since the round hits its tick ceiling first. FromInt now panics outside the Q32.32 integer range instead of wrapping a positive input into a negative value. Raises coverage to 88% overall; adds a Makefile with db-reset, since the append-only ledger steadily consumes bridge headroom across test runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
648 lines
16 KiB
Go
648 lines
16 KiB
Go
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); 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); err == nil {
|
|
t.Fatal("bet accepted while settled")
|
|
}
|
|
|
|
f.openBetting()
|
|
f.startRun()
|
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); 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); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 1_000); 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); 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); 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); 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)
|
|
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)
|
|
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); 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); 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); 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); 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)
|
|
}(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)
|
|
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)
|
|
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())
|
|
}
|
|
}
|