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:
prosolis
2026-07-14 21:12:37 -07:00
parent 6d402343e6
commit ba306f0b59
9 changed files with 646 additions and 12 deletions

View File

@@ -60,7 +60,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
var rosterPushOK bool
func (p *AdventurePlugin) pushRoster() {
snap, err := buildRosterSnapshot(time.Now().UTC())
snap, err := buildRosterSnapshot(time.Now().UTC(), p.euro)
if err != nil {
slog.Error("roster: build snapshot failed", "err", err)
return
@@ -96,8 +96,8 @@ func (p *AdventurePlugin) pushRoster() {
// anonymized. A standing row showing class + level + zone is trivially
// re-identifiable (there is one level-14 cleric), so "an adventurer" would have
// been a fig leaf; absence is the only honest option.
func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix()}
func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnapshot, error) {
snap := peteclient.RosterSnapshot{SnapshotAt: now.Unix(), Tiers: mischiefTierCatalog()}
// Both DATETIME columns selected raw and folded in Go — NOT COALESCE()'d in
// SQL. modernc.org/sqlite rebuilds a time.Time from the column's *declared*
@@ -133,6 +133,22 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
}
for _, pl := range players {
// The buyer's own advisory balance rides along in a separate keyspace,
// keyed by localpart (their sign-in name), and is collected *before* the
// opt-out skip: opt-out hides a player from the public board, but their own
// balance is private on Pete — only ever read for the user asking about
// themselves — so there is no reason to deny an opted-out player the
// storefront's affordability hint. localpart is lowercase, matching how the
// buyer signs in and how a web order's username is resolved back to an MXID.
if euro != nil {
if lp := localpartOf(pl.uid); lp != "" {
snap.Balances = append(snap.Balances, peteclient.MischiefBalance{
Username: lp,
Euro: euro.GetBalance(pl.uid),
})
}
}
if isNewsOptedOut(pl.uid) {
continue
}
@@ -175,3 +191,47 @@ func buildRosterSnapshot(now time.Time) (peteclient.RosterSnapshot, error) {
}
return snap, nil
}
// resolveRosterToken maps a board token back to the adventurer it names. The
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
// recompute every live player's token and match. The salt is DB-persisted, so a
// token minted before a restart still resolves after one. O(players) per call,
// which is nothing at realm scale and only runs when a web order is being placed.
func resolveRosterToken(token string) (id.UserID, bool) {
if token == "" {
return "", false
}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil {
return "", false
}
defer rows.Close()
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
if eventToken(id.UserID(uid), "roster") == token {
return id.UserID(uid), true
}
}
return "", false
}
// mischiefTierCatalog is the storefront price list, pushed on every tick so the
// web shop always renders gogobee's current prices — a fee retune here reaches
// Pete within a snapshot, and Pete never hardcodes a number of its own. The
// signed fee is the same +25% sink a Matrix buyer pays to put their name on it.
func mischiefTierCatalog() []peteclient.MischiefTier {
out := make([]peteclient.MischiefTier, 0, len(mischiefTiers))
for _, t := range mischiefTiers {
out = append(out, peteclient.MischiefTier{
Key: t.Key,
Display: t.Display,
Fee: t.Fee,
SignedFee: mischiefSignedFee(t.Fee),
Blurb: t.Blurb,
})
}
return out
}