games: gogobee learns to poll Pete for money it has to move

Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.

It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.

The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.

First GET gogobee has ever made to Pete.
This commit is contained in:
prosolis
2026-07-13 23:00:11 -07:00
parent ab2bcf0c59
commit 6d402343e6
16 changed files with 5051 additions and 15 deletions

View File

@@ -0,0 +1,165 @@
package plugin
import (
"context"
"log/slog"
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The casino's money loop.
//
// Pete runs the games and holds the chips; we hold the euros and are the only
// one who can move them. A player who buys in or cashes out on games.parodia.dev
// opens an escrow row over there, and this loop is what turns it into a real
// balance change over here.
//
// It has to be a poll because Pete cannot call us — there is no inbound API on
// this box and there isn't going to be one. That constraint is what shaped the
// whole design: money crosses the border twice per *session*, not twice per
// hand, so a few seconds of poll latency lands on "buying chips…" and never on
// "deal me in".
//
// Every step is idempotent on the escrow guid, because every step can be
// interrupted in the worst possible place. If we die after DebitIdem and before
// the verdict is queued, Pete re-offers the row, we claim it again, DebitIdem
// replays as a no-op and reports the same answer, and the verdict goes out. The
// player is charged once. That is the only property here that really matters.
const (
// escrowPollInterval — a player is watching a spinner, so this is seconds,
// not the 30s the mischief seam gets away with.
escrowPollInterval = 3 * time.Second
// escrowPollTimeout — one full poll-claim-settle pass. Generous: the
// alternative to finishing is a claimed row nobody answers.
escrowPollTimeout = 30 * time.Second
reasonInsufficientFunds = "insufficient_funds"
)
// StartPeteEscrowLoop runs the border forever. Safe to call when the Pete seam
// is off — it simply never starts.
func StartPeteEscrowLoop(ctx context.Context, euro *EuroPlugin) {
if !peteclient.Enabled() || euro == nil {
return
}
slog.Info("pete games: escrow loop started", "interval", escrowPollInterval)
go func() {
t := time.NewTicker(escrowPollInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pollEscrowOnce(ctx, euro)
}
}
}()
}
// escrowPollOK tracks the poll's health so we log transitions and not a line
// every three seconds. Pete being down is normal during its redeploys and is not
// worth shouting about; Pete being down for an hour is.
var escrowPollOK = true
func pollEscrowOnce(ctx context.Context, euro *EuroPlugin) {
ctx, cancel := context.WithTimeout(ctx, escrowPollTimeout)
defer cancel()
pending, err := peteclient.PendingEscrow(ctx)
if err != nil {
if escrowPollOK {
slog.Warn("pete games: escrow poll failed — buy-ins and cash-outs are stalled", "err", err)
escrowPollOK = false
}
return
}
if !escrowPollOK {
slog.Info("pete games: escrow poll recovered")
escrowPollOK = true
}
if len(pending) == 0 {
return
}
for _, e := range pending {
if ctx.Err() != nil {
return
}
settleEscrow(ctx, euro, e)
}
// The verdicts are on the durable queue now. Send them at once rather than
// waiting up to a sender tick — there is a person at the other end of this.
peteclient.Flush(ctx)
}
// settleEscrow moves the euros for one crossing and queues the answer.
func settleEscrow(ctx context.Context, euro *EuroPlugin, e peteclient.Escrow) {
// Claim first. The claim is what fixes the amount and the player: the poll's
// copy of the row is already a few milliseconds stale, and this is money.
claimed, err := peteclient.ClaimEscrow(ctx, e.GUID)
if err != nil {
slog.Warn("pete games: claim failed, will retry", "guid", e.GUID, "err", err)
return
}
// Pete has already decided this one — a stale re-offer that raced our own
// earlier verdict. Nothing to do, and nothing went wrong.
if claimed.State != "claimed" {
slog.Debug("pete games: escrow already decided", "guid", e.GUID, "state", claimed.State)
return
}
if claimed.Amount <= 0 {
slog.Error("pete games: escrow with a non-positive amount, refusing",
"guid", claimed.GUID, "amount", claimed.Amount)
return
}
user := id.UserID(claimed.MatrixUser)
amount := float64(claimed.Amount)
var ok bool
var balance float64
switch claimed.Kind {
case "buyin":
ok, balance, err = euro.DebitIdem(user, amount, "games_buyin", claimed.GUID)
case "cashout":
ok, balance, err = euro.CreditIdem(user, amount, "games_cashout", claimed.GUID)
default:
// Not a transient failure and not something a retry fixes. Say no, so the
// row reaches a terminal state on Pete instead of being re-offered forever.
slog.Error("pete games: unknown escrow kind", "guid", claimed.GUID, "kind", claimed.Kind)
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID, OK: false, Reason: "unknown_kind",
})
return
}
if err != nil {
// The money did not move and we don't know that it didn't — so say
// nothing. Pete re-offers the row once the claim goes stale, and the guid
// makes the retry safe whichever way this actually landed.
slog.Error("pete games: euro move failed, leaving the row for a retry",
"guid", claimed.GUID, "kind", claimed.Kind, "err", err)
return
}
reason := ""
if !ok {
// The only way DebitIdem says no: the player cannot cover it. A cash-out
// has no floor to breach, so this is always a buy-in.
reason = reasonInsufficientFunds
}
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID,
OK: ok,
Reason: reason,
BalanceAfter: balance,
})
slog.Info("pete games: escrow moved", "guid", claimed.GUID, "user", claimed.MatrixUser,
"kind", claimed.Kind, "amount", claimed.Amount, "ok", ok, "balance_after", balance)
}

View File

@@ -0,0 +1,222 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// fakePete is Pete's half of the escrow wire: it offers rows, lets us claim
// them, and records the verdicts we push back. Enough to drive the loop end to
// end without either real box.
type fakePete struct {
mu sync.Mutex
pending []peteclient.Escrow
claimed map[string]bool
verdicts []peteclient.EscrowVerdict
srv *httptest.Server
}
func newFakePete(t *testing.T, token string, rows ...peteclient.Escrow) *fakePete {
t.Helper()
f := &fakePete{pending: rows, claimed: map[string]bool{}}
mux := http.NewServeMux()
auth := func(r *http.Request) bool { return r.Header.Get("Authorization") == "Bearer "+token }
mux.HandleFunc("GET /api/games/escrow/pending", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
f.mu.Lock()
defer f.mu.Unlock()
_ = json.NewEncoder(w).Encode(f.pending)
})
mux.HandleFunc("POST /api/games/escrow/claim", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var req struct{ GUID string }
_ = json.NewDecoder(r.Body).Decode(&req)
f.mu.Lock()
defer f.mu.Unlock()
for _, e := range f.pending {
if e.GUID != req.GUID {
continue
}
f.claimed[e.GUID] = true
e.State = "claimed"
_ = json.NewEncoder(w).Encode(e)
return
}
w.WriteHeader(404)
})
mux.HandleFunc("POST /api/games/escrow/settled", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var v peteclient.EscrowVerdict
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(400)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.verdicts = append(f.verdicts, v)
w.WriteHeader(200)
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// wire points the peteclient singleton at the fake and turns the seam on.
func (f *fakePete) wire(t *testing.T, token string) {
t.Helper()
t.Setenv("PETE_INGEST_URL", f.srv.URL)
t.Setenv("PETE_INGEST_TOKEN", token)
t.Setenv("FEATURE_PETE_NEWS", "true")
peteclient.Init()
}
func (f *fakePete) got() []peteclient.EscrowVerdict {
f.mu.Lock()
defer f.mu.Unlock()
return append([]peteclient.EscrowVerdict(nil), f.verdicts...)
}
// TestEscrowLoopBuyInDebitsOnceAndReportsIt is the happy path across the border:
// Pete offers a buy-in, we take the euros, and the verdict lands back on Pete
// carrying the balance the player now has.
func TestEscrowLoopBuyInDebitsOnceAndReportsIt(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@reala:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-1", MatrixUser: string(user), Kind: "buyin", Amount: 400, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 600 {
t.Fatalf("balance = %v, want 600 (1000 less a 400 buy-in)", got)
}
v := pete.got()
if len(v) != 1 || !v[0].OK || v[0].GUID != "esc-1" {
t.Fatalf("verdicts = %+v, want one ok verdict for esc-1", v)
}
if v[0].BalanceAfter != 600 {
t.Fatalf("balance_after = %v, want 600", v[0].BalanceAfter)
}
}
// TestEscrowLoopReofferedRowDebitsOnce is the failure this whole design is built
// around. We move the euros, then die before the verdict is delivered. Pete
// re-offers the row. The player must not pay twice.
func TestEscrowLoopReofferedRowDebitsOnce(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@twice:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-dup", MatrixUser: string(user), Kind: "buyin", Amount: 250, State: "requested",
})
pete.wire(t, "tok")
// Three passes over a row Pete keeps offering, as it would after a crash.
for i := 0; i < 3; i++ {
pollEscrowOnce(context.Background(), euro)
}
if got := euro.GetBalance(user); got != 750 {
t.Fatalf("balance = %v, want 750 — the re-offered row debited more than once", got)
}
// The verdict is queued under the escrow guid, so it is enqueued once no
// matter how many times we decide it. Pete's settle is idempotent anyway,
// but the queue is the first line of that defence.
if v := pete.got(); len(v) != 1 {
t.Fatalf("delivered %d verdicts for one row, want 1: %+v", len(v), v)
}
}
// TestEscrowLoopBrokePlayerIsRejectedCleanly — the debit is refused. Nothing may
// move, and Pete has to be told why, because the player is staring at a spinner
// that needs to become a sentence.
func TestEscrowLoopBrokePlayerIsRejectedCleanly(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@broke:parodia.dev")
seedBalance(t, euro, user, 10)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-broke", MatrixUser: string(user), Kind: "buyin", Amount: 5000, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 10 {
t.Fatalf("balance = %v, want 10 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != reasonInsufficientFunds {
t.Fatalf("verdicts = %+v, want one rejection carrying insufficient_funds", v)
}
}
// TestEscrowLoopCashOutCredits — the way out of the casino.
func TestEscrowLoopCashOutCredits(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@winner:parodia.dev")
seedBalance(t, euro, user, 100)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-out", MatrixUser: string(user), Kind: "cashout", Amount: 900, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 1000 {
t.Fatalf("balance = %v, want 1000", got)
}
if v := pete.got(); len(v) != 1 || !v[0].OK || v[0].BalanceAfter != 1000 {
t.Fatalf("verdicts = %+v, want one ok cash-out at 1000", v)
}
}
// TestEscrowLoopUnknownKindIsRefusedNotRetried — a row we can't interpret. A
// silent skip would leave it re-offered forever, so say no and let it reach a
// terminal state on Pete.
func TestEscrowLoopUnknownKindIsRefusedNotRetried(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@weird:parodia.dev")
seedBalance(t, euro, user, 500)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-weird", MatrixUser: string(user), Kind: "tribute", Amount: 100, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 500 {
t.Fatalf("balance = %v, want 500 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != "unknown_kind" {
t.Fatalf("verdicts = %+v, want one refusal", v)
}
}