mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
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).
This commit is contained in:
86
internal/plugin/pete_mischief.go
Normal file
86
internal/plugin/pete_mischief.go
Normal file
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user