feat(admin): operations console, fees wired into payouts

Fees now flow through settlement. The payout and the deduction are posted
as separate ledger transactions rather than netted, so a player's history
shows the full win and the charge as itemised lines instead of a quietly
smaller win.

The admin console shows treasury, liability, revenue, every posting,
every round, and risk flags. Auth is a constant-time token compare and
the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a
default deployment has no admin endpoint to attack. The token lives in
browser memory only.

It is read-only over game outcomes by design: seeds show only after
settlement and nothing can alter a crash point. A control that could
would make the fairness proof a lie.

The console immediately found a real bug: 343 unresolved rounds, because
the reconciler only considered rounds with bets and abandoned empty ones
accumulated forever, burying the signal. Now cleared automatically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 23:34:16 +00:00
parent e70258c54d
commit 1da3b6760e
9 changed files with 1315 additions and 15 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
@@ -21,11 +22,13 @@ import (
"github.com/coder/websocket"
"github.com/drjones/quantum-arcade/pkg/cluster"
"github.com/drjones/quantum-arcade/pkg/fair"
"github.com/drjones/quantum-arcade/pkg/fees"
"github.com/drjones/quantum-arcade/pkg/fixed"
"github.com/drjones/quantum-arcade/pkg/identity"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/drjones/quantum-arcade/pkg/room"
"github.com/drjones/quantum-arcade/pkg/scratch"
"github.com/drjones/quantum-arcade/pkg/sim"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
@@ -184,6 +187,11 @@ 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)
// Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment
// has no admin surface at all.
s.routesAdmin(mux)
sub, err := fs.Sub(staticFiles, "static")
if err != nil {
@@ -608,6 +616,32 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
})
}
// 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.
func (s *server) handleFees(w http.ResponseWriter, r *http.Request) {
sch := fees.DefaultSchedule()
tickets := make([]map[string]any, 0, len(scratch.Catalog))
for _, t := range scratch.Catalog {
tickets = append(tickets, map[string]any{
"ticket": t.Name,
"game_rtp_percent": fmt.Sprintf("%.2f%%", float64(t.RTPBasisPoints())/100),
"effective_percent": fmt.Sprintf("%.2f%%",
float64(sch.EffectiveRTPBasisPoints(int64(t.RTPBasisPoints())))/100),
})
}
crashRTP := int64(10000 - sim.HouseEdgeBP)
writeJSON(w, http.StatusOK, map[string]any{
"schedule": sch.Describe(crashRTP),
"crash_games": map[string]string{
"game_rtp_percent": fmt.Sprintf("%.2f%%", float64(crashRTP)/100),
"effective_percent": fmt.Sprintf("%.2f%%",
float64(sch.EffectiveRTPBasisPoints(crashRTP))/100),
},
"scratch_tickets": tickets,
})
}
// handleCluster reports the instances currently serving and which of them
// drives each game. This is the operator's view of a cloned fleet.
func (s *server) handleCluster(w http.ResponseWriter, r *http.Request) {