Files
gogobee/internal/plugin/pete_mischief.go
prosolis ba306f0b59 mischief: gogobee learns to fill orders placed on Pete's web board
The game-side half of Mischief Makers M3. gogobee polls Pete for storefront
orders and opens the contract itself — the money, the eligibility, the fight
are all its own, exactly as a Matrix !mischief buy.

- roster push now carries each buyer's advisory euro balance (keyed by localpart,
  a separate keyspace from the anonymous board token) and the live tier catalog,
  so the storefront renders gogobee's current prices and never hardcodes one
- placeWebMischief: the fulfilment path, debit-first-then-refund so the money
  state is a pure function of the order guid. DebitIdem + an order_guid stamp on
  the contract make a retried claim neither double-charge nor double-open
- resolveRosterToken recomputes the one-way board token per live player to name
  a mark; the buyer is @<username>:<homeserver> from Authentik's preferred_username
- a 30s poll loop drives it; a lost verdict just leaves the order pending to
  re-run, so the loop is its own retry and needs no durable queue
- euro.HasExternalTx lets the affordability gate tell a first attempt (no debt
  for a mischief buy, like Matrix) from a retry of one already paid

order_guid column added to mischief_contracts (schema + idempotent ALTER).
2026-07-14 21:12:37 -07:00

87 lines
3.1 KiB
Go

package plugin
// The mischief storefront's game-side loop.
//
// A buyer places a hit on Pete's web board; Pete records the intent and waits.
// We poll for those orders, do the real work against our own ledger — the money,
// the eligibility, the contract — and hand back a verdict Pete files against the
// order. Pete has no route into this box, which is why the traffic runs this way:
// we ask for work, we don't get told about it.
//
// The order guid is the idempotency key end to end (see placeWebMischief), so
// every step here is safe to repeat. That is the whole reason the loop can be
// this simple: a failed claim just leaves the order pending, and the next tick
// re-runs it as a no-op. The loop is its own retry, and needs no durable queue.
import (
"context"
"log/slog"
"time"
"gogobee/internal/peteclient"
)
const (
mischiefPollInterval = 30 * time.Second
mischiefPollTimeout = 20 * time.Second
)
// peteMischiefTicker polls Pete for storefront orders and fulfils them. Started
// alongside the other adventure tickers; exits on stopCh.
func (p *AdventurePlugin) peteMischiefTicker() {
if !peteclient.Enabled() {
return // no Pete wire configured; the storefront half is simply off
}
ticker := time.NewTicker(mischiefPollInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.pollMischiefOrders()
}
}
}
// pollMischiefOrders fetches the pending orders and fulfils each in turn.
func (p *AdventurePlugin) pollMischiefOrders() {
ctx, cancel := context.WithTimeout(context.Background(), mischiefPollTimeout)
defer cancel()
orders, err := peteclient.PendingMischief(ctx)
if err != nil {
// A Pete that predates the storefront answers 404 here; a wire blip is the
// same. Either way there is nothing to do this tick, and the next one tries
// again. Debug, not warn — this must be quiet when Pete simply hasn't
// shipped the endpoint yet.
slog.Debug("mischief: poll failed", "err", err)
return
}
for _, order := range orders {
p.fulfilMischiefOrder(ctx, order)
}
}
// fulfilMischiefOrder places one order's contract and files the verdict. A
// transient failure (Retry) is left pending for the next poll; a real verdict is
// pushed back so the buyer sees why. A failed claim-push is not fatal — the order
// stays pending and the next poll re-runs it, which the guid makes a no-op.
func (p *AdventurePlugin) fulfilMischiefOrder(ctx context.Context, order peteclient.MischiefOrder) {
res := p.placeWebMischief(order)
if res.Retry {
return // leave it pending; try again next tick
}
if err := peteclient.ClaimMischief(ctx, order.GUID, res.Status, res.Detail); err != nil {
// The contract is placed (or the buyer refunded) on our side, but Pete
// hasn't heard the verdict. It stays pending there and we'll re-offer it;
// placeWebMischief will short-circuit on the stamped order guid and we'll
// re-file. So this is a warn, not a lost order.
slog.Warn("mischief: claim verdict push failed, will re-file next poll",
"order", order.GUID, "status", res.Status, "err", err)
return
}
slog.Info("mischief: web order fulfilled", "order", order.GUID, "status", res.Status)
}