mischief: a storefront where money buys a stranger some trouble

The web half of Mischief Makers M3. A signed-in buyer picks a mark off the
anonymous roster board and pays for a monster to find them; gogobee does the
real work and hands back a verdict Pete files against the order.

- mischief_orders: intent in, verdict out, idempotent on a guid that is the
  end-to-end key gogobee passes to DebitIdem and stamps on the contract
- user_euro + mischief_tiers: advisory balance and the live price list, pushed
  on the roster tick so the storefront never hardcodes a number that can drift
- OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire
- roster board grows a 'send trouble' button, a tier picker, and a status panel

Pete never touches money and never runs a game rule. It records what a buyer
wants and what gogobee said happened.
This commit is contained in:
prosolis
2026-07-14 20:55:15 -07:00
parent 983748ea98
commit 2ac6ec6b91
9 changed files with 1315 additions and 6 deletions

View File

@@ -41,13 +41,26 @@ const (
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
//
// Balances ride the same tick as the board but live in a separate keyspace: they
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
// roster token, and Pete only ever reads one back for the authenticated user
// asking about themselves. So the board stays anonymous while the storefront can
// still grey out tiers a buyer plainly can't afford.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
Balances []storage.MischiefBalance `json:"balances,omitempty"`
Tiers []storage.MischiefTier `json:"tiers,omitempty"`
}
// RosterView is one row as the page renders it.
//
// Token is the mark's anonymous roster token, exposed so the storefront can name
// a target without ever knowing their real handle. It is safe on a public page by
// design — non-reversible, stable, and already how gogobee keys the board.
type RosterView struct {
Token string
Name string
Level int
ClassRace string
@@ -103,7 +116,21 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
// Advisory balances are best-effort: a board that landed is worth keeping
// even if the balances behind it didn't, so a failure here is logged, not
// fatal. Stale affordability only ever bounces an order, never miscounts money.
if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
slog.Error("roster ingest: balance replace failed", "err", err)
}
// The tier catalog is likewise best-effort and only refreshed when the push
// actually carried one, so a gogobee build that predates the storefront (no
// tiers field) can't wipe a catalog a newer one already established.
if len(push.Tiers) > 0 {
if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
slog.Error("roster ingest: tier replace failed", "err", err)
}
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
w.WriteHeader(http.StatusOK)
}
@@ -142,6 +169,7 @@ func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Token: e.Token,
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,