feat(cluster): zero-config horizontal scaling by cloning
An instance decides what it is at startup instead of being told: it generates its own identity, registers a heartbeat, and campaigns for each game. Exactly one instance drives a game's rounds and publishes frames; the rest relay them and forward mutations to the leader. Clone the VM, boot it, done. Sessions and the scratch nonce move to Redis. Both were per-instance state that would have broken behind a load balancer: a token minted by one clone was unknown to the others, and two clones would have handed the same nonce to different players, which for the same key means the same outcome. Fixes a bug found by running two instances: /api/games read the local room object, so a follower reported a permanently settled game and its clients never saw a betting window. Hubs now serve the last frame they saw, produced or relayed. Failover measured at 6s after kill -9 on an instance leading two games. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
95
cmd/arcade/forward.go
Normal file
95
cmd/arcade/forward.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// forwardClient is deliberately short-tempered. A bet or cash-out is only
|
||||
// useful while the round is still open, so a request that cannot be delivered
|
||||
// promptly should fail loudly rather than land after the moment has passed.
|
||||
var forwardClient = &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
// forwardToLeader proxies a mutation to whichever instance drives the game.
|
||||
//
|
||||
// Only one instance holds a game's authoritative round state — who is in, at
|
||||
// what stake, and at which tick each cash-out landed. Serving a bet from a
|
||||
// follower's idle copy would either fail or, worse, create a second version of
|
||||
// the round. So the follower relays the request and returns the leader's
|
||||
// answer verbatim.
|
||||
//
|
||||
// The caller's Authorization header travels with it. That works because
|
||||
// sessions live in Redis, so the leader can validate a token issued by any
|
||||
// instance in the fleet.
|
||||
//
|
||||
// It reports whether the request was handled here.
|
||||
func (s *server) forwardToLeader(w http.ResponseWriter, r *http.Request, game string, body []byte) bool {
|
||||
hub, ok := s.hubs[game]
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "no such game")
|
||||
return true
|
||||
}
|
||||
if hub.Leading() {
|
||||
return false // this instance owns the round; handle it locally
|
||||
}
|
||||
|
||||
leader, err := s.node.LeaderOf(r.Context(), game)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "cannot locate the game leader")
|
||||
return true
|
||||
}
|
||||
if leader.Address == "" {
|
||||
// Between leaders: a lease has expired and the next campaign has not
|
||||
// landed yet. This resolves within a couple of seconds on its own.
|
||||
writeErr(w, http.StatusServiceUnavailable,
|
||||
"this game is changing hands; try again in a moment")
|
||||
return true
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s%s", leader.Address, r.URL.Path)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return true
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
// Mark the hop so a routing mistake shows up as an explicit loop error
|
||||
// rather than as instances bouncing a request between themselves.
|
||||
if r.Header.Get("X-Arcade-Forwarded") != "" {
|
||||
writeErr(w, http.StatusLoopDetected,
|
||||
"request was forwarded twice; the cluster disagrees about the leader")
|
||||
return true
|
||||
}
|
||||
req.Header.Set("X-Arcade-Forwarded", s.node.ID)
|
||||
|
||||
res, err := forwardClient.Do(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable,
|
||||
"the instance running this game did not respond")
|
||||
return true
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "truncated response from the game leader")
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(res.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
return true
|
||||
}
|
||||
|
||||
// readBody buffers a request body so it can be both parsed locally and
|
||||
// forwarded if this instance turns out not to own the game.
|
||||
func readBody(r *http.Request) ([]byte, error) {
|
||||
defer r.Body.Close()
|
||||
return io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
}
|
||||
271
cmd/arcade/hub.go
Normal file
271
cmd/arcade/hub.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/cluster"
|
||||
"github.com/drjones/quantum-arcade/pkg/room"
|
||||
)
|
||||
|
||||
// gameHub owns one game across the cluster.
|
||||
//
|
||||
// Exactly one instance leads a game: it runs the round loop, drives the
|
||||
// simulation, settles to the ledger, and publishes each frame. Every other
|
||||
// instance relays those frames to its own connected clients. Clients cannot
|
||||
// tell the difference, and neither can the ledger.
|
||||
//
|
||||
// Roles are renegotiated on a timer rather than agreed once, so an instance
|
||||
// disappearing is not a special case — its lease simply stops being renewed
|
||||
// and the next campaign hands the game to someone else.
|
||||
type gameHub struct {
|
||||
game string
|
||||
room *room.Room
|
||||
node *cluster.Node
|
||||
|
||||
mu sync.RWMutex
|
||||
leading bool
|
||||
subs map[chan []byte]struct{}
|
||||
|
||||
// lastFrame is the most recent frame this instance saw, whether it
|
||||
// produced it or relayed it. A follower's own room object sits idle, so
|
||||
// this — not the local room — is what any read of "current state" must
|
||||
// use, or a follower would report a permanently settled game.
|
||||
lastFrame []byte
|
||||
|
||||
// cancelLead stops the leader's round loop when leadership is lost.
|
||||
cancelLead context.CancelFunc
|
||||
}
|
||||
|
||||
func newGameHub(game string, r *room.Room, node *cluster.Node) *gameHub {
|
||||
return &gameHub{
|
||||
game: game,
|
||||
room: r,
|
||||
node: node,
|
||||
subs: make(map[chan []byte]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe returns frames for this game, whether this instance is producing
|
||||
// them or relaying them.
|
||||
func (h *gameHub) Subscribe() (<-chan []byte, func()) {
|
||||
ch := make(chan []byte, 4)
|
||||
h.mu.Lock()
|
||||
h.subs[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs, ch)
|
||||
close(ch)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// fanout delivers a frame to this instance's own clients. A client that has
|
||||
// stopped reading is skipped rather than allowed to stall the game.
|
||||
func (h *gameHub) fanout(payload []byte) {
|
||||
h.mu.Lock()
|
||||
h.lastFrame = payload
|
||||
h.mu.Unlock()
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for ch := range h.subs {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LastFrame returns the most recent state this instance knows about, and
|
||||
// whether it has seen one yet.
|
||||
func (h *gameHub) LastFrame() ([]byte, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.lastFrame, len(h.lastFrame) > 0
|
||||
}
|
||||
|
||||
// Leading reports whether this instance currently drives the game.
|
||||
func (h *gameHub) Leading() bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.leading
|
||||
}
|
||||
|
||||
// supervise campaigns for the game and switches roles as leadership moves.
|
||||
func (h *gameHub) supervise(ctx context.Context) {
|
||||
// Followers hold a subscription to the cluster's frame channel. It is torn
|
||||
// down on promotion so a leader never relays its own frames back to itself.
|
||||
var stopRelay func()
|
||||
defer func() {
|
||||
if stopRelay != nil {
|
||||
stopRelay()
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(cluster.RenewInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.demote()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
var held bool
|
||||
var err error
|
||||
if h.Leading() {
|
||||
held, err = h.node.Renew(ctx, h.game)
|
||||
} else {
|
||||
held, err = h.node.Campaign(ctx, h.game)
|
||||
}
|
||||
if err != nil {
|
||||
// Redis is unreachable. A current leader keeps running rather than
|
||||
// abandoning a round mid-flight; its lease will expire and another
|
||||
// instance will take over if the outage outlasts it.
|
||||
log.Printf("hub %s: coordination error: %v", h.game, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case held && !h.Leading():
|
||||
if stopRelay != nil {
|
||||
stopRelay()
|
||||
stopRelay = nil
|
||||
}
|
||||
h.promote(ctx)
|
||||
|
||||
case !held && h.Leading():
|
||||
h.demote()
|
||||
stopRelay = h.startRelay(ctx)
|
||||
|
||||
case !held && stopRelay == nil:
|
||||
// Follower with no relay yet — subscribe so clients see the game.
|
||||
stopRelay = h.startRelay(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// promote starts driving the game on this instance.
|
||||
func (h *gameHub) promote(ctx context.Context) {
|
||||
leadCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
h.mu.Lock()
|
||||
h.leading = true
|
||||
h.cancelLead = cancel
|
||||
h.mu.Unlock()
|
||||
|
||||
log.Printf("hub %s: leading", h.game)
|
||||
|
||||
// Forward the room's frames both to this instance's clients and to peers.
|
||||
frames, unsubscribe := h.room.Subscribe()
|
||||
go func() {
|
||||
defer unsubscribe()
|
||||
for {
|
||||
select {
|
||||
case <-leadCtx.Done():
|
||||
return
|
||||
case payload, ok := <-frames:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.fanout(payload)
|
||||
if err := h.node.PublishFrame(leadCtx, h.game, payload); err != nil &&
|
||||
!errors.Is(err, context.Canceled) {
|
||||
log.Printf("hub %s: publishing frame: %v", h.game, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := h.room.Run(leadCtx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("hub %s: round loop stopped: %v", h.game, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// demote stops driving the game.
|
||||
func (h *gameHub) demote() {
|
||||
h.mu.Lock()
|
||||
wasLeading := h.leading
|
||||
h.leading = false
|
||||
cancel := h.cancelLead
|
||||
h.cancelLead = nil
|
||||
h.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if wasLeading {
|
||||
log.Printf("hub %s: no longer leading", h.game)
|
||||
}
|
||||
}
|
||||
|
||||
// startRelay subscribes to the leader's frames and passes them to this
|
||||
// instance's clients.
|
||||
func (h *gameHub) startRelay(ctx context.Context) func() {
|
||||
frames, unsubscribe := h.node.SubscribeFrames(ctx, h.game)
|
||||
relayCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-relayCtx.Done():
|
||||
return
|
||||
case payload, ok := <-frames:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.fanout(payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
cancel()
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// advertiseAddr is how peers reach this instance.
|
||||
//
|
||||
// It prefers an explicit setting, then the first non-loopback address it can
|
||||
// find — so a cloned VM that gets its address from DHCP advertises correctly
|
||||
// without being told what it is.
|
||||
func advertiseAddr() string {
|
||||
if v := os.Getenv("ARCADE_ADVERTISE"); v != "" {
|
||||
return v
|
||||
}
|
||||
port := os.Getenv("ARCADE_ADDR")
|
||||
if port == "" {
|
||||
port = ":8080"
|
||||
}
|
||||
if !strings.HasPrefix(port, ":") {
|
||||
if _, p, err := net.SplitHostPort(port); err == nil {
|
||||
port = ":" + p
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "127.0.0.1" + port
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
return ipnet.IP.String() + port
|
||||
}
|
||||
return "127.0.0.1" + port
|
||||
}
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/drjones/quantum-arcade/pkg/cluster"
|
||||
"github.com/drjones/quantum-arcade/pkg/fair"
|
||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||
"github.com/drjones/quantum-arcade/pkg/identity"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"github.com/drjones/quantum-arcade/pkg/room"
|
||||
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -44,16 +46,21 @@ type server struct {
|
||||
ledger *ledger.Ledger
|
||||
auth *identity.Authenticator
|
||||
rooms map[string]*room.Room
|
||||
hubs map[string]*gameHub
|
||||
|
||||
// sessions maps a bearer token to a verified public key. Sessions live in
|
||||
// memory only: restarting the server signs everyone out, which is fine for
|
||||
// a machine you own and means there is no session store to leak.
|
||||
sessMu sync.RWMutex
|
||||
sessions map[string]string
|
||||
// Sessions live in Redis rather than instance memory. With several cloned
|
||||
// instances behind one endpoint, a token issued by one must be accepted by
|
||||
// all of them — otherwise every request would have to return to the
|
||||
// instance that happened to handle the sign-in.
|
||||
rdb *redis.Client
|
||||
node *cluster.Node
|
||||
|
||||
// scratchNonce advances per play so each ticket has a distinct seed.
|
||||
nonceMu sync.Mutex
|
||||
scratchNonce uint64
|
||||
//
|
||||
// It is drawn from Redis rather than a local counter: with several
|
||||
// instances serving, two clones would otherwise hand the same nonce to
|
||||
// different players, and identical nonces mean identical outcomes for the
|
||||
// same key. The counter is shared, so every ticket is distinct fleet-wide.
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -78,6 +85,19 @@ func main() {
|
||||
log.Fatalf("database unreachable: %v", err)
|
||||
}
|
||||
|
||||
// Redis carries sessions and cluster coordination. Every cloned instance
|
||||
// points at the same one; that plus the same database is the entire
|
||||
// configuration a clone needs.
|
||||
redisAddr := os.Getenv("ARCADE_REDIS")
|
||||
if redisAddr == "" {
|
||||
redisAddr = "localhost:6379"
|
||||
}
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisAddr})
|
||||
defer rdb.Close()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
log.Fatalf("redis unreachable at %s: %v", redisAddr, err)
|
||||
}
|
||||
|
||||
// Every ticket in the catalog must have coherent odds before we serve it.
|
||||
for _, t := range scratch.Catalog {
|
||||
if err := t.Validate(); err != nil {
|
||||
@@ -86,21 +106,32 @@ func main() {
|
||||
}
|
||||
|
||||
s := &server{
|
||||
pool: pool,
|
||||
ledger: ledger.New(pool),
|
||||
auth: identity.NewAuthenticator(),
|
||||
rooms: make(map[string]*room.Room),
|
||||
sessions: make(map[string]string),
|
||||
pool: pool,
|
||||
ledger: ledger.New(pool),
|
||||
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
|
||||
// id and joins the cluster without anyone editing a file.
|
||||
s.node = cluster.NewNode(rdb, advertiseAddr())
|
||||
if err := s.node.Start(ctx); err != nil {
|
||||
log.Fatalf("joining cluster: %v", err)
|
||||
}
|
||||
defer s.node.Stop(context.Background())
|
||||
log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)
|
||||
|
||||
// One hub per game. Each hub campaigns for leadership: the winner drives
|
||||
// the rounds and publishes frames, the rest relay those frames to their
|
||||
// own clients. Roles are renegotiated continuously, so losing an instance
|
||||
// hands its rooms over without intervention.
|
||||
for _, g := range games {
|
||||
r := room.New(g, pool, s.ledger)
|
||||
s.rooms[g] = r
|
||||
go func(r *room.Room) {
|
||||
if err := r.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("room %s stopped: %v", r.Game, err)
|
||||
}
|
||||
}(r)
|
||||
h := newGameHub(g, room.New(g, pool, s.ledger), s.node)
|
||||
s.rooms[g] = h.room
|
||||
s.hubs[g] = h
|
||||
go h.supervise(ctx)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -147,6 +178,7 @@ func (s *server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/dev/faucet", s.handleFaucet)
|
||||
}
|
||||
mux.HandleFunc("GET /ws/{game}", s.handleWS)
|
||||
mux.HandleFunc("GET /api/cluster", s.handleCluster)
|
||||
|
||||
sub, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
@@ -179,16 +211,28 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// SessionTTL bounds how long a token stays valid without use.
|
||||
const SessionTTL = 24 * time.Hour
|
||||
|
||||
// session resolves the caller's public key from the Authorization header.
|
||||
func (s *server) session(r *http.Request) (string, bool) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if len(token) > 7 && token[:7] == "Bearer " {
|
||||
token = token[7:]
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
s.sessMu.RLock()
|
||||
defer s.sessMu.RUnlock()
|
||||
pk, ok := s.sessions[token]
|
||||
return pk, ok
|
||||
pk, err := s.rdb.Get(r.Context(), "qa:session:"+token).Result()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return pk, true
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
token := r.Header.Get("Authorization")
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
return token[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// account resolves the caller to a ledger account id.
|
||||
@@ -276,9 +320,10 @@ func (s *server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
tokenBytes = seed.Bytes()
|
||||
token := hex.EncodeToString(tokenBytes[:])
|
||||
|
||||
s.sessMu.Lock()
|
||||
s.sessions[token] = req.Pubkey
|
||||
s.sessMu.Unlock()
|
||||
if err := s.rdb.Set(r.Context(), "qa:session:"+token, req.Pubkey, SessionTTL).Err(); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not store session")
|
||||
return
|
||||
}
|
||||
|
||||
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -348,9 +393,23 @@ func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]room.Snapshot, 0, len(s.rooms))
|
||||
// Serve the last frame each hub saw rather than the local room object:
|
||||
// on an instance that does not lead a game, the local room is idle and
|
||||
// would report a game that never starts.
|
||||
out := make([]json.RawMessage, 0, len(games))
|
||||
for _, g := range games {
|
||||
out = append(out, s.rooms[g].Snapshot())
|
||||
hub := s.hubs[g]
|
||||
if frame, ok := hub.LastFrame(); ok {
|
||||
out = append(out, json.RawMessage(frame))
|
||||
continue
|
||||
}
|
||||
// Nothing seen yet — fall back to the local view, which is correct
|
||||
// during the moment before the first frame arrives.
|
||||
snap, err := json.Marshal(s.rooms[g].Snapshot())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, json.RawMessage(snap))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rooms": out})
|
||||
}
|
||||
@@ -361,6 +420,11 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "could not read request")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Game string `json:"game"`
|
||||
StakeMsat int64 `json:"stake_msat"`
|
||||
@@ -369,7 +433,7 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
||||
// Zero or absent means no target.
|
||||
AutoCashOut float64 `json:"auto_cashout"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||
return
|
||||
}
|
||||
@@ -378,6 +442,10 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, "no such game")
|
||||
return
|
||||
}
|
||||
// Only the instance driving this game holds the authoritative round.
|
||||
if s.forwardToLeader(w, r, req.Game, body) {
|
||||
return
|
||||
}
|
||||
// Convert the target to fixed-point at the boundary; everything past this
|
||||
// point is integer arithmetic.
|
||||
var target fixed.F
|
||||
@@ -403,10 +471,15 @@ func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "could not read request")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Game string `json:"game"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "malformed request")
|
||||
return
|
||||
}
|
||||
@@ -415,6 +488,9 @@ func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, "no such game")
|
||||
return
|
||||
}
|
||||
if s.forwardToLeader(w, r, req.Game, body) {
|
||||
return
|
||||
}
|
||||
at, err := rm.CashOut(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
@@ -480,10 +556,12 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.nonceMu.Lock()
|
||||
s.scratchNonce++
|
||||
nonce := s.scratchNonce
|
||||
s.nonceMu.Unlock()
|
||||
n, err := s.rdb.Incr(r.Context(), "qa:scratch:nonce").Result()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "could not allocate a nonce")
|
||||
return
|
||||
}
|
||||
nonce := uint64(n)
|
||||
|
||||
server := fair.NewServerSeed()
|
||||
outcome, proof := scratch.PlayFromRound(ticket, server, pk, nonce, req.StakeMsat)
|
||||
@@ -525,6 +603,36 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
members, err := s.node.Members(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
leaders := make(map[string]any, len(games))
|
||||
for _, g := range games {
|
||||
m, err := s.node.LeaderOf(r.Context(), g)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
leaders[g] = map[string]any{
|
||||
"instance": m.ID,
|
||||
"hostname": m.Hostname,
|
||||
"address": m.Address,
|
||||
"is_me": m.ID == s.node.ID,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"this_instance": map[string]string{
|
||||
"id": s.node.ID, "hostname": s.node.Hostname, "address": s.node.Address,
|
||||
},
|
||||
"members": members,
|
||||
"leaders": leaders,
|
||||
})
|
||||
}
|
||||
|
||||
// handleFaucet credits the caller from the bridge account. Development only.
|
||||
func (s *server) handleFaucet(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.account(r)
|
||||
@@ -608,11 +716,12 @@ func (s *server) handleVerifyRound(w http.ResponseWriter, r *http.Request) {
|
||||
// handleWS streams round snapshots to a client.
|
||||
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
game := r.PathValue("game")
|
||||
rm, ok := s.rooms[game]
|
||||
hub, ok := s.hubs[game]
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "no such game")
|
||||
return
|
||||
}
|
||||
rm := hub.room
|
||||
|
||||
// The server is LAN-only, so any origin on the local network is acceptable.
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
@@ -624,7 +733,9 @@ func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
defer conn.CloseNow()
|
||||
|
||||
ctx := r.Context()
|
||||
updates, unsubscribe := rm.Subscribe()
|
||||
// Frames come from the hub, which produces them when this instance leads
|
||||
// the game and relays the leader's when it does not.
|
||||
updates, unsubscribe := hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
// Send the current state immediately so a joining phone is never blank.
|
||||
|
||||
Reference in New Issue
Block a user