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>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -483,12 +484,36 @@ func TestFullPlayerJourney(t *testing.T) {
|
||||
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.
|
||||
// reached the target, nothing otherwise — net of the disclosed fees.
|
||||
var after struct {
|
||||
BalanceMsat int64 `json:"balance_msat"`
|
||||
}
|
||||
|
||||
// Derive the expected payout from the published fee schedule rather than
|
||||
// hardcoding it. A test that hardcodes the net would silently stop
|
||||
// checking anything the moment the operator changed the rake.
|
||||
var sched struct {
|
||||
Schedule struct {
|
||||
RakePercent string `json:"rake_percent"`
|
||||
RoundingUnit string `json:"rounding_unit"`
|
||||
} `json:"schedule"`
|
||||
}
|
||||
alice.do("GET", "/api/fees", nil, &sched)
|
||||
|
||||
var rakePct float64
|
||||
fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
|
||||
var roundUnit int64
|
||||
fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
|
||||
if roundUnit < 1 {
|
||||
roundUnit = 1
|
||||
}
|
||||
|
||||
gross := int64(stake) * 3 / 2
|
||||
rake := int64(float64(gross) * rakePct / 100)
|
||||
net := (gross - rake) / roundUnit * roundUnit
|
||||
|
||||
// Settlement posts a moment after the reveal; poll briefly.
|
||||
wantWin := beforeRound - stake + stake*3/2
|
||||
wantWin := beforeRound - stake + net
|
||||
wantLose := beforeRound - stake
|
||||
ok := false
|
||||
for i := 0; i < 20; i++ {
|
||||
@@ -504,8 +529,9 @@ func TestFullPlayerJourney(t *testing.T) {
|
||||
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)
|
||||
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
|
||||
"(gross %d, rake %d, net %d)",
|
||||
crash, after.BalanceMsat, wantWin, gross, rake, net)
|
||||
}
|
||||
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/drjones/quantum-arcade/pkg/room"
|
||||
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -45,11 +46,12 @@ const maxAutoCashOut = 1_000_000
|
||||
var games = []string{"rocket", "orbital", "tower"}
|
||||
|
||||
type server struct {
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
auth *identity.Authenticator
|
||||
rooms map[string]*room.Room
|
||||
hubs map[string]*gameHub
|
||||
pool *pgxpool.Pool
|
||||
ledger *ledger.Ledger
|
||||
auth *identity.Authenticator
|
||||
rooms map[string]*room.Room
|
||||
tournaments *tournament.Service
|
||||
hubs map[string]*gameHub
|
||||
|
||||
// Sessions live in Redis rather than instance memory. With several cloned
|
||||
// instances behind one endpoint, a token issued by one must be accepted by
|
||||
@@ -108,13 +110,15 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
l := ledger.New(pool)
|
||||
s := &server{
|
||||
pool: pool,
|
||||
ledger: ledger.New(pool),
|
||||
auth: identity.NewAuthenticator(),
|
||||
rooms: make(map[string]*room.Room),
|
||||
hubs: make(map[string]*gameHub),
|
||||
rdb: rdb,
|
||||
pool: pool,
|
||||
ledger: l,
|
||||
tournaments: tournament.New(pool, l),
|
||||
auth: identity.NewAuthenticator(),
|
||||
rooms: make(map[string]*room.Room),
|
||||
hubs: make(map[string]*gameHub),
|
||||
rdb: rdb,
|
||||
}
|
||||
|
||||
// Identity is generated, not configured: a cloned VM boots with its own
|
||||
@@ -142,6 +146,23 @@ func main() {
|
||||
// concurrent sweeps refund exactly once.
|
||||
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
||||
|
||||
// Move tournaments through their lifecycle by wall clock. Every instance
|
||||
// runs this; the updates are idempotent, so it needs no leader.
|
||||
go func() {
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.tournaments.AdvanceSchedules(ctx); err != nil {
|
||||
log.Printf("tournaments: advancing schedules: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.routes(),
|
||||
@@ -188,6 +209,9 @@ func (s *server) routes() http.Handler {
|
||||
mux.HandleFunc("GET /ws/{game}", s.handleWS)
|
||||
mux.HandleFunc("GET /api/cluster", s.handleCluster)
|
||||
mux.HandleFunc("GET /api/fees", s.handleFees)
|
||||
mux.HandleFunc("GET /api/tournaments", s.handleTournaments)
|
||||
mux.HandleFunc("GET /api/tournaments/{id}/leaderboard", s.handleLeaderboard)
|
||||
mux.HandleFunc("POST /api/tournaments/{id}/enter", s.handleEnterTournament)
|
||||
|
||||
// Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment
|
||||
// has no admin surface at all.
|
||||
@@ -616,6 +640,49 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleTournaments lists what a player can currently join or watch.
|
||||
func (s *server) handleTournaments(w http.ResponseWriter, r *http.Request) {
|
||||
active, err := s.tournaments.Active(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tournaments": active})
|
||||
}
|
||||
|
||||
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||
return
|
||||
}
|
||||
board, err := s.tournaments.Leaderboard(r.Context(), id, 100)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"standings": board})
|
||||
}
|
||||
|
||||
func (s *server) handleEnterTournament(w http.ResponseWriter, r *http.Request) {
|
||||
account, _, ok := s.account(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||
return
|
||||
}
|
||||
if err := s.tournaments.Enter(r.Context(), id, account); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
bal, _ := s.ledger.Balance(r.Context(), account)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||
}
|
||||
|
||||
// handleFees publishes exactly what the operator takes. It is generated from
|
||||
// the same schedule the code charges, so the published terms cannot drift from
|
||||
// the behaviour.
|
||||
|
||||
Reference in New Issue
Block a user