Files
Pete/internal/web/games.go
prosolis 44613c4760 games: the wire the euros cross
Three bearer-authed endpoints and gogobee can work the border: poll what's
waiting, claim a row, report what happened to the money. The storage layer
underneath was already done; this is the transport, and deliberately nothing
more.

All three are idempotent, because the thing on the other end of them is a
retrying queue and the thing they move is money. A verdict delivered three
times creates chips once. A rejected buy-in moves nothing and clears the
pending amount so it stops eating the table cap. A cash-out gogobee couldn't
pay gives the chips back rather than vanishing them from both sides.

A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has
by then moved real euros against it, and no amount of retrying invents the
missing row. Under the contract the adventure seam set, a 400 parks it in
gogobee's queue where a human can find it.
2026-07-13 23:00:19 -07:00

152 lines
5.4 KiB
Go

package web
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"pete/internal/storage"
)
// The euro/chip wire.
//
// gogobee owns the euros and has no inbound API, so it is the only initiator:
// it polls this endpoint for border crossings, moves the money on its side, and
// pushes the verdict back through the durable queue it already uses for
// adventure facts. Pete never calls gogobee. That direction of travel is a
// standing rule of the seam, not an implementation detail — see roster.go.
//
// All three endpoints are bearer-authed with the same ingest token as the
// adventure seam, and all three are idempotent, because the thing on the other
// end of them is a retrying queue and the thing they move is money.
//
// The storage layer under this (internal/storage/games.go) is where the actual
// invariant lives: chips exist only once gogobee confirms it took the euros.
// These handlers are transport, and deliberately nothing more.
// escrowGUID is the body of the two POSTs that name a row.
type escrowGUID struct {
GUID string `json:"guid"`
}
// escrowVerdict is gogobee's answer: did the money move, and what is the
// player's euro balance now.
type escrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
// handleEscrowPending is gogobee's poll: every crossing waiting to be moved.
//
// It includes rows gogobee claimed but never reported on — see
// storage.PendingEscrow. Re-offering those is the whole reason the guid is an
// idempotency key: if gogobee already moved the euros, the retry is a no-op
// that reports the same answer, and if it died before moving them, the money
// gets moved now instead of being stranded.
func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rows, err := storage.PendingEscrow(escrowPollLimit)
if err != nil {
slog.Error("games: pending escrow", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if rows == nil {
rows = []storage.Escrow{} // an empty poll is [], never null
}
writeJSON(w, rows)
}
// escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog
// drains in a handful of ticks rather than arriving as one enormous body.
const escrowPollLimit = 50
// handleEscrowClaim marks a row as taken. It is not a lock — a row already
// claimed can be claimed again, which is how a stale re-offer works — but a row
// that has already reached a verdict cannot be, which is what stops a settled
// cash-out being paid twice.
//
// The claimed row goes back in the response, so gogobee moves the money against
// the amount and the user *Pete* holds rather than the ones it read a poll ago.
func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req escrowGUID
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.ClaimEscrow(req.GUID)
if errors.Is(err, storage.ErrNoSuchEscrow) {
http.Error(w, "no such escrow", http.StatusNotFound)
return
}
if err != nil {
slog.Error("games: claim escrow", "guid", req.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, e)
}
// handleEscrowSettled applies gogobee's verdict. This is the only way chips are
// ever created, and it runs exactly once per guid no matter how many times the
// push is redelivered.
//
// An unknown guid is a 400 rather than a shrug: gogobee has, by this point,
// already moved real euros for a row Pete has no record of. Under the contract
// the adventure seam established, a 400 makes gogobee's sender park the row
// instead of retrying it forever — which is right, because no amount of retrying
// invents the missing row. It leaves the payload sitting in gogobee's queue,
// where a human can find it.
func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) {
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var v escrowVerdict
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if v.GUID == "" {
http.Error(w, "guid is required", http.StatusBadRequest)
return
}
e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter)
if errors.Is(err, storage.ErrNoSuchEscrow) {
slog.Error("games: verdict for an escrow row we have never heard of — "+
"gogobee has moved euros against it and Pete cannot honour them",
"guid", v.GUID, "ok", v.OK)
http.Error(w, "no such escrow", http.StatusBadRequest)
return
}
if err != nil {
slog.Error("games: settle escrow", "guid", v.GUID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser,
"kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason)
writeJSON(w, e)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("games: write response", "err", err)
}
}