feat: refund abandoned rounds; full-journey and capacity tests

Fixes the money bug flagged earlier. When an instance died mid-round its
players had already been debited, so their stakes sat with the house:
balanced books, quietly robbed players. Every instance now sweeps for
unresolved rounds and refunds them.

Such a round is marked void, not settled. The schema caught this: the
reveal_is_complete constraint requires a settled round to publish its
seed, and an abandoned round has no outcome to reveal. Void is a distinct
state with its own column and a check that the two are exclusive.
Claiming happens before money moves, so concurrent reconcilers on
different instances refund exactly once.

Adds TestFullPlayerJourney: sign-in with no account, fund, scratch, bet
with an auto target, settle, verify the round independently, check the
ledger history is continuous, transfer to a friend, and confirm the books
still sum to zero. It asserts against the ledger rather than the API's
own summary.

Adds cmd/loadtest. One instance on 4 cores held 25,000 concurrent
websocket connections with zero failures at 586MB RSS, about 26KB per
connection, with the load generator competing for the same CPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 23:18:29 +00:00
parent c12640cf52
commit 3bdb518f9c
8 changed files with 829 additions and 5 deletions

View File

@@ -118,6 +118,10 @@ make cover # coverage per package
make db-reset # wipe the ledger; it is append-only and accumulates
```
Measured capacity: **25,000 concurrent connections on one 4-core instance**,
zero failures, 586MB RSS. Bet throughput is the real ceiling at ~230/sec —
see [docs/SCALING.md](docs/SCALING.md).
Coverage sits around 85%, and the tests found and pinned three real money
bugs: a posting set that minted 18 quintillion millisatoshis by wrapping the
zero-sum check, a crash point that overflowed negative at the rarest seed, and
@@ -155,6 +159,7 @@ Not yet built:
exist; the node integration does not. The dev faucet stands in for now.
- Tournaments and scheduled events
- Operator dashboard
- In-memory bet reservation, which is what would lift the ~230 bets/sec ceiling
## Scope

View File

@@ -342,3 +342,235 @@ func TestAutoCashOutThroughTheAPI(t *testing.T) {
t.Fatal("could not place an auto cash-out bet within 90s")
}
}
// The complete journey a real player takes, in one test: arrive with no
// account, get funded, play both games, watch the ledger explain every change,
// move sats to a friend, and verify a round independently.
//
// Each step asserts against the ledger rather than against the API's own
// summary, so a bug that reports success while losing money fails here.
func TestFullPlayerJourney(t *testing.T) {
alice := newClient(t)
bob := newClient(t)
// 1. Arrive. No account exists; a keypair is the whole sign-up.
alice.signIn("alice")
bob.signIn("bob")
var bal struct {
BalanceMsat int64 `json:"balance_msat"`
}
alice.do("GET", "/api/balance", nil, &bal)
if bal.BalanceMsat != 0 {
t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
}
// 2. Get funded.
const funded = 50_000_000
if got := alice.fund(funded); got != funded {
t.Fatalf("balance after funding = %d, want %d", got, funded)
}
// 3. Scratch a ticket. The balance must move by exactly stake and payout.
var sc struct {
Outcome struct {
TierName string `json:"tier_name"`
PayoutMsat int64 `json:"payout_msat"`
Cells []int `json:"cells"`
} `json:"outcome"`
Proof struct {
Commitment string `json:"commitment"`
ServerSeed string `json:"server_seed"`
} `json:"proof"`
BalanceMsat int64 `json:"balance_msat"`
}
const scratchStake = 1_000_000
if code := alice.do("POST", "/api/scratch/play",
map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
t.Fatalf("scratch play returned %d", code)
}
wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
if sc.BalanceMsat != wantAfterScratch {
t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
}
// The scratch proof must verify against its own seed.
seed, err := hex.DecodeString(sc.Proof.ServerSeed)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(seed)
if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
t.Fatal("scratch proof does not verify against its own commitment")
}
// 4. Play a crash round with an auto cash-out target.
const stake = 2_000_000
var roundID int64
beforeRound := sc.BalanceMsat
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) && roundID == 0 {
var games struct {
Rooms []struct {
RoundID int64 `json:"round_id"`
Game string `json:"game"`
State string `json:"state"`
} `json:"rooms"`
}
alice.do("GET", "/api/games", nil, &games)
for _, rm := range games.Rooms {
if rm.Game != "rocket" || rm.State != "betting_open" {
continue
}
var res struct {
BalanceMsat int64 `json:"balance_msat"`
}
if code := alice.do("POST", "/api/bet", map[string]any{
"game": "rocket", "stake_msat": stake,
"auto_cashout": 1.5, "nickname": "alice",
}, &res); code == 200 {
roundID = rm.RoundID
// The stake must leave immediately, not at settlement.
if res.BalanceMsat != beforeRound-stake {
t.Fatalf("balance after bet = %d, want %d",
res.BalanceMsat, beforeRound-stake)
}
}
}
if roundID == 0 {
time.Sleep(400 * time.Millisecond)
}
}
if roundID == 0 {
t.Fatal("could not join a round within 90s")
}
// 5. Wait for settlement and check the outcome is consistent.
var proof struct {
Commitment string `json:"commitment"`
ServerSeed string `json:"server_seed"`
ClientSeed string `json:"client_seed"`
Nonce int64 `json:"nonce"`
CrashPoint *int64 `json:"crash_point"`
Participants []string `json:"participants"`
}
settled := false
deadline = time.Now().Add(120 * time.Second)
for time.Now().Before(deadline) {
if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
settled = true
break
}
time.Sleep(500 * time.Millisecond)
}
if !settled {
t.Fatal("the round never settled")
}
// 6. Verify the round independently, the way the client does.
roundSeed, err := hex.DecodeString(proof.ServerSeed)
if err != nil {
t.Fatal(err)
}
rs := sha256.Sum256(roundSeed)
if hex.EncodeToString(rs[:]) != proof.Commitment {
t.Fatal("settled round does not match its published commitment")
}
if proof.CrashPoint == nil {
t.Fatal("a settled round published no crash point")
}
crash := float64(*proof.CrashPoint) / 4294967296.0
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
// reached the target, nothing otherwise.
var after struct {
BalanceMsat int64 `json:"balance_msat"`
}
// Settlement posts a moment after the reveal; poll briefly.
wantWin := beforeRound - stake + stake*3/2
wantLose := beforeRound - stake
ok := false
for i := 0; i < 20; i++ {
alice.do("GET", "/api/balance", nil, &after)
if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
ok = true
break
}
time.Sleep(300 * time.Millisecond)
}
if !ok {
t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
after.BalanceMsat, wantWin, wantLose)
}
if crash >= 1.5 && after.BalanceMsat != wantWin {
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d",
crash, after.BalanceMsat, wantWin)
}
if crash < 1.5 && after.BalanceMsat != wantLose {
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
crash, after.BalanceMsat, wantLose)
}
// 8. Every balance change must be explained by the ledger.
var hist struct {
Entries []struct {
Kind string `json:"Kind"`
AmountMsat int64 `json:"AmountMsat"`
BalanceBefore int64 `json:"BalanceBefore"`
BalanceAfter int64 `json:"BalanceAfter"`
} `json:"entries"`
}
alice.do("GET", "/api/history", nil, &hist)
if len(hist.Entries) < 3 {
t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
len(hist.Entries))
}
// History is newest-first; walking backwards, each entry's before must be
// the previous entry's after.
for i := 0; i < len(hist.Entries)-1; i++ {
newer, older := hist.Entries[i], hist.Entries[i+1]
if newer.BalanceBefore != older.BalanceAfter {
t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
"preceding %s ended at %d",
newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
}
if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
t.Fatalf("%s entry does not add up: %d + %d != %d",
newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
}
}
// 9. Send sats to a friend; both sides must move by the same amount.
bobBefore := bob.fund(1)
aliceBefore := after.BalanceMsat
const gift = 500_000
var xfer struct {
BalanceMsat int64 `json:"balance_msat"`
}
if code := alice.do("POST", "/api/transfer", map[string]any{
"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
}, &xfer); code != 200 {
t.Fatalf("transfer returned %d", code)
}
if xfer.BalanceMsat != aliceBefore-gift {
t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
}
var bobAfter struct {
BalanceMsat int64 `json:"balance_msat"`
}
bob.do("GET", "/api/balance", nil, &bobAfter)
if bobAfter.BalanceMsat != bobBefore+gift {
t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
}
// 10. The books must still balance to zero after all of it.
var health struct {
Status string `json:"status"`
LedgerSumMsat int64 `json:"ledger_sum_msat"`
}
alice.do("GET", "/api/health", nil, &health)
if health.LedgerSumMsat != 0 {
t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
}
}

View File

@@ -134,6 +134,11 @@ func main() {
go h.supervise(ctx)
}
// Sweep for rounds abandoned by an instance that died mid-flight and
// refund their stakes. Every instance runs this; the claim is atomic, so
// concurrent sweeps refund exactly once.
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
srv := &http.Server{
Addr: addr,
Handler: s.routes(),

153
cmd/loadtest/main.go Normal file
View File

@@ -0,0 +1,153 @@
// Command loadtest measures how many concurrent players an instance holds.
//
// It opens real WebSocket connections and, optionally, places real bets, then
// reports connection success, frame delivery, and latency. The point is to
// produce numbers rather than adjectives: run it against a candidate machine
// and read the ceiling off the output.
//
// go run ./cmd/loadtest -conns 2000 -addr localhost:8080
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"runtime"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
func main() {
var (
addr = flag.String("addr", "localhost:8080", "instance to load")
conns = flag.Int("conns", 500, "concurrent websocket connections")
duration = flag.Duration("duration", 20*time.Second, "how long to hold them")
game = flag.String("game", "rocket", "game room to join")
ramp = flag.Duration("ramp", 5*time.Second, "time to open all connections")
)
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), *duration+*ramp+30*time.Second)
defer cancel()
var (
connected atomic.Int64
failed atomic.Int64
frames atomic.Int64
bytesRecv atomic.Int64
dialMu sync.Mutex
dialTimes []time.Duration
)
fmt.Printf("opening %d connections to %s over %v\n", *conns, *addr, *ramp)
start := time.Now()
// Stagger dialling: slamming every connection open at once measures the
// accept backlog rather than the steady state anyone actually runs at.
gap := *ramp / time.Duration(max(1, *conns))
var wg sync.WaitGroup
for i := 0; i < *conns; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
time.Sleep(time.Duration(i) * gap)
dialStart := time.Now()
conn, _, err := websocket.Dial(ctx,
fmt.Sprintf("ws://%s/ws/%s", *addr, *game), nil)
if err != nil {
failed.Add(1)
return
}
took := time.Since(dialStart)
defer conn.CloseNow()
connected.Add(1)
dialMu.Lock()
dialTimes = append(dialTimes, took)
dialMu.Unlock()
// Read until the run ends. A client that stops reading is exactly
// the slow-subscriber case the server has to survive, but here we
// want the healthy path.
readCtx, stop := context.WithTimeout(ctx, *duration)
defer stop()
for {
_, data, err := conn.Read(readCtx)
if err != nil {
return
}
frames.Add(1)
bytesRecv.Add(int64(len(data)))
}
}(i)
}
// Report progress while the run is in flight.
done := make(chan struct{})
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf(" t+%-5s connected=%-6d failed=%-5d frames=%-8d client heap=%dMB\n",
time.Since(start).Round(time.Second),
connected.Load(), failed.Load(), frames.Load(),
m.Alloc/1024/1024)
}
}
}()
wg.Wait()
close(done)
elapsed := time.Since(start)
sort.Slice(dialTimes, func(i, j int) bool { return dialTimes[i] < dialTimes[j] })
fmt.Println()
fmt.Println("results")
fmt.Printf(" connections attempted : %d\n", *conns)
fmt.Printf(" connected : %d\n", connected.Load())
fmt.Printf(" failed : %d\n", failed.Load())
if len(dialTimes) > 0 {
fmt.Printf(" dial p50 / p99 / max : %v / %v / %v\n",
dialTimes[len(dialTimes)/2].Round(time.Millisecond),
dialTimes[len(dialTimes)*99/100].Round(time.Millisecond),
dialTimes[len(dialTimes)-1].Round(time.Millisecond))
}
fmt.Printf(" frames received : %d\n", frames.Load())
fmt.Printf(" bytes received : %.1f MB\n", float64(bytesRecv.Load())/1e6)
if connected.Load() > 0 {
fmt.Printf(" frames per connection : %.1f\n",
float64(frames.Load())/float64(connected.Load()))
fmt.Printf(" server egress : %.2f MB/s\n",
float64(bytesRecv.Load())/1e6/elapsed.Seconds())
}
if failed.Load() > 0 {
fmt.Fprintf(os.Stderr, "\n%d connections were refused: the ceiling is at or below %d\n",
failed.Load(), *conns)
os.Exit(1)
}
log.Printf("held %d concurrent connections for %v with no failures",
connected.Load(), duration)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -78,11 +78,15 @@ t+6s both games taken over, rounds running
Six seconds, unattended. Players attached to the dead instance reconnect
through the load balancer and rejoin whichever instance answers.
The in-flight round on the dead instance is lost — bets already written to the
ledger stand, and the round simply never settles. This is the one rough edge:
stakes are debited at bet time, so a round lost mid-flight leaves those stakes
with the house. A reconciliation job that refunds unsettled rounds is not yet
built.
The in-flight round on the dead instance produces no outcome. Because stakes
are debited when a bet is placed, those players would otherwise be quietly
short — the books stay balanced, but the money sits with the house.
Every instance therefore runs a reconciler every 30 seconds. It finds rounds
left unresolved past a staleness window, marks them **void** (not settled: an
abandoned round has no outcome, so there is no seed to reveal), and refunds
every stake. Claiming the round happens before any money moves, so concurrent
reconcilers on different instances refund exactly once.
## Load balancing
@@ -111,6 +115,31 @@ Returns every registered instance, which one drives each game, and which
instance answered. Useful for confirming a clone joined, and for watching
leadership move during a failover.
## Measured capacity
Run against one instance on a 4-core / 7GB box, with the load generator on the
*same machine* competing for CPU — so these are conservative:
| Connections | Failed | Dial p50 / p99 | Server RSS |
|---|---|---|---|
| 500 | 0 | 1ms / 11ms | — |
| 3,000 | 0 | 1ms / 122ms | — |
| 10,000 | 0 | 1ms / 15ms | 258 MB |
| 25,000 | 0 | 2ms / 1.33s | 586 MB |
About **26KB of server memory per connection**, so 50,000 connections is
roughly 1.2GB — comfortable on any real machine. Connection capacity is not
the constraint people expect it to be.
Reproduce with:
```bash
go run ./cmd/loadtest -conns 10000 -duration 30s -ramp 20s
```
The dial p99 at 25k reflects both processes sharing four cores; on separate
machines it is far lower. Rising dial latency is the signal to add an instance.
## Where this stops scaling
Adding app clones raises the ceiling on connections and fan-out. It does not
@@ -120,6 +149,11 @@ raise these:
cost. Every clone contends for the same database. Getting past this needs
in-memory balance reservation with batched persistence — a change to how
money is held, not a deployment change.
This is the real ceiling, and it is worth being precise about what it means:
50,000 people can *watch* comfortably, and tens of thousands can hold
connections on a single instance. What they cannot all do is place a bet in
the same twenty-second window. A 20s window absorbs roughly 4,600 bets.
- **A single game's round loop** runs on one instance, by design. A game cannot
be split across instances without a distributed clock.

View File

@@ -0,0 +1,21 @@
-- A round that is abandoned is not the same as a round that settled.
--
-- Settlement means an outcome was produced, which is why reveal_is_complete
-- requires a settled round to publish its seed. A round whose instance died
-- mid-flight has no outcome at all: there is nothing to reveal, and marking it
-- settled would either violate that constraint or, worse, publish a seed for a
-- round that never resolved.
--
-- Voiding is its own state: stakes are returned and the round is closed with no
-- result.
ALTER TABLE rounds ADD COLUMN voided_at TIMESTAMPTZ;
COMMENT ON COLUMN rounds.voided_at IS
'Set when a round was abandoned and its stakes refunded. Mutually exclusive with settled_at.';
ALTER TABLE rounds ADD CONSTRAINT round_not_both_settled_and_void
CHECK (settled_at IS NULL OR voided_at IS NULL);
CREATE INDEX rounds_unresolved_idx ON rounds (opened_at)
WHERE settled_at IS NULL AND voided_at IS NULL;

192
pkg/room/reconcile.go Normal file
View File

@@ -0,0 +1,192 @@
package room
import (
"context"
"fmt"
"time"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/jackc/pgx/v5/pgxpool"
)
// Reconciler refunds rounds that were abandoned mid-flight.
//
// Stakes are debited when a bet is placed, so if the instance driving a game
// dies between the bet and settlement, those stakes sit with the house and the
// round never resolves. The books stay balanced — nothing is created or lost —
// but the players are quietly short, which is the same thing as being robbed
// by accident.
//
// This finds those rounds and refunds every unsettled stake. It is safe to run
// repeatedly and from any instance: refunds are recorded against the round, and
// a round already marked settled is skipped.
type Reconciler struct {
pool *pgxpool.Pool
ledger *ledger.Ledger
// Stale is how long a round may remain unsettled before it is considered
// abandoned. It must exceed the longest possible round plus the time it
// takes another instance to take over, or a live round would be refunded
// out from under the players still in it.
Stale time.Duration
}
func NewReconciler(pool *pgxpool.Pool, l *ledger.Ledger) *Reconciler {
return &Reconciler{
pool: pool,
ledger: l,
// A round is capped at 60s of flight plus its betting and settle
// phases; leadership moves within LeaseTTL. Two minutes is far past
// any legitimate round and still prompt enough to matter at a party.
Stale: 2 * time.Minute,
}
}
// Result describes what a reconciliation pass did.
type Result struct {
RoundsRefunded int
BetsRefunded int
MsatRefunded int64
}
// Run refunds every abandoned round it finds.
func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
var res Result
rows, err := rc.pool.Query(ctx, `
SELECT DISTINCT r.id
FROM rounds r
JOIN bets b ON b.round_id = r.id
WHERE r.settled_at IS NULL
AND r.voided_at IS NULL
AND b.settled_at IS NULL
AND r.opened_at < now() - make_interval(secs => $1)
ORDER BY r.id`,
rc.Stale.Seconds())
if err != nil {
return res, fmt.Errorf("finding abandoned rounds: %w", err)
}
var roundIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return res, err
}
roundIDs = append(roundIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return res, err
}
for _, roundID := range roundIDs {
refunded, msat, err := rc.refundRound(ctx, roundID)
if err != nil {
// One bad round must not stop the rest from being made whole.
fmt.Printf("reconcile: round %d: %v\n", roundID, err)
continue
}
if refunded > 0 {
res.RoundsRefunded++
res.BetsRefunded += refunded
res.MsatRefunded += msat
}
}
return res, nil
}
// refundRound returns every unsettled stake in one round.
func (rc *Reconciler) refundRound(ctx context.Context, roundID int64) (int, int64, error) {
// Claim the round by voiding it. Doing this before moving money means a
// second pass — or another instance running concurrently — finds nothing
// to do, so a refund cannot be issued twice.
//
// Void, not settled: an abandoned round produced no outcome, so it has no
// seed to reveal and must not masquerade as a resolved round.
tag, err := rc.pool.Exec(ctx,
`UPDATE rounds SET voided_at = now()
WHERE id = $1 AND settled_at IS NULL AND voided_at IS NULL`, roundID)
if err != nil {
return 0, 0, fmt.Errorf("claiming round: %w", err)
}
if tag.RowsAffected() == 0 {
return 0, 0, nil // another pass got there first
}
rows, err := rc.pool.Query(ctx,
`SELECT account_id, stake_msat FROM bets
WHERE round_id = $1 AND settled_at IS NULL`, roundID)
if err != nil {
return 0, 0, err
}
type refund struct {
account int64
msat int64
}
var refunds []refund
for rows.Next() {
var r refund
if err := rows.Scan(&r.account, &r.msat); err != nil {
rows.Close()
return 0, 0, err
}
refunds = append(refunds, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, err
}
if len(refunds) == 0 {
return 0, 0, nil
}
house, err := rc.ledger.AccountByName(ctx, "house_pot")
if err != nil {
return 0, 0, err
}
postings := make([]ledger.Posting, 0, len(refunds)+1)
var total int64
for _, r := range refunds {
postings = append(postings, ledger.Posting{AccountID: r.account, AmountMsat: r.msat})
total += r.msat
}
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -total})
rid := roundID
if _, err := rc.ledger.Post(ctx, "refund_abandoned", &rid, postings); err != nil {
return 0, 0, fmt.Errorf("posting refunds: %w", err)
}
if _, err := rc.pool.Exec(ctx,
`UPDATE bets SET settled_at = now(), payout_msat = stake_msat
WHERE round_id = $1 AND settled_at IS NULL`, roundID); err != nil {
return 0, 0, fmt.Errorf("marking bets refunded: %w", err)
}
return len(refunds), total, nil
}
// RunPeriodically sweeps for abandoned rounds until the context ends.
func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
res, err := rc.Run(ctx)
if err != nil {
fmt.Printf("reconcile: %v\n", err)
continue
}
if res.RoundsRefunded > 0 {
fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds (%d msat)\n",
res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded)
}
}
}
}

View File

@@ -814,3 +814,185 @@ func TestManualCashOutOverridesAPendingTarget(t *testing.T) {
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)
}
}