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

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