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

@@ -0,0 +1,336 @@
package storage
import (
"database/sql"
"errors"
"fmt"
)
// The mischief storefront's half of the euro border.
//
// A buyer signs in, picks a mark off the anonymous roster board, and places a
// hit. Pete records only the *intent*: it never moves money and never runs a
// single game rule. gogobee's poll loop reads the pending orders, does the real
// work against its own ledger (debit, eligibility, open the contract), and hands
// back a verdict Pete files against the order. The guid is the idempotency key
// end to end — gogobee passes it to DebitIdem and stamps it on the contract — so
// a verdict whose ack is lost on the wire can be retried without the buyer paying
// twice or the mark catching two hits for one order.
// MischiefOrder is one storefront order and its current standing.
type MischiefOrder struct {
GUID string `json:"guid"`
BuyerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
BuyerUsername string `json:"buyer_username"` // localpart gogobee turns into @username:server
TargetToken string `json:"target_token"`
TargetName string `json:"target_name"`
Tier string `json:"tier"`
Signed bool `json:"signed"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at,omitempty"`
}
// Order states. These strings cross the wire to gogobee (it POSTs the verdict),
// so they are part of the contract — see the schema and gogobee_mischief_plan.md.
const (
MischiefPending = "pending" // placed; gogobee hasn't acted yet
MischiefPlaced = "placed" // gogobee debited the buyer and opened a contract
MischiefBouncedFunds = "bounced_funds" // buyer couldn't afford it after all
MischiefBouncedIneligible = "bounced_ineligible" // target no longer a valid mark
)
// validMischiefVerdict is the set of terminal states gogobee is allowed to hand
// back. An unknown verdict is a contract mismatch, not something to file blindly.
func validMischiefVerdict(status string) bool {
switch status {
case MischiefPlaced, MischiefBouncedFunds, MischiefBouncedIneligible:
return true
}
return false
}
var ErrNoSuchOrder = errors.New("mischief: no such order")
// InsertMischiefOrder records a fresh, pending order and returns it with a new
// guid. Money and eligibility are gogobee's problem; all Pete asserts here is
// that the buyer is signed in and the form was well formed (checked by the
// caller). The guid is minted here so the buyer sees a stable reference the
// instant they submit, before gogobee has ever heard of it.
func InsertMischiefOrder(buyerSub, buyerUsername, targetToken, targetName, tier string, signed bool) (MischiefOrder, error) {
guid, err := newGUID()
if err != nil {
return MischiefOrder{}, err
}
now := nowUnix()
sig := 0
if signed {
sig = 1
}
if _, err := Get().Exec(
`INSERT INTO mischief_orders
(guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
guid, buyerSub, buyerUsername, targetToken, targetName, tier, sig, MischiefPending, now, now,
); err != nil {
return MischiefOrder{}, fmt.Errorf("mischief: insert order: %w", err)
}
return MischiefOrder{
GUID: guid, BuyerSub: buyerSub, BuyerUsername: buyerUsername,
TargetToken: targetToken, TargetName: targetName, Tier: tier,
Signed: signed, Status: MischiefPending, CreatedAt: now, UpdatedAt: now,
}, nil
}
// PendingMischiefOrders is what gogobee's poll loop reads: every order still
// waiting to be acted on. There is no claimed-but-stale window like escrow has,
// because a pending order carries no intermediate state — if gogobee dies partway
// through, the order simply stays pending and is offered again next poll, and the
// guid makes the replay a no-op.
func PendingMischiefOrders(limit int) ([]MischiefOrder, error) {
if limit <= 0 {
limit = 100
}
rows, err := Get().Query(
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
FROM mischief_orders
WHERE status = ?
ORDER BY created_at
LIMIT ?`,
MischiefPending, limit,
)
if err != nil {
return nil, fmt.Errorf("mischief: pending orders: %w", err)
}
defer rows.Close()
return scanMischiefOrders(rows)
}
// ResolveMischiefOrder files gogobee's verdict against a pending order. It is
// idempotent: gogobee's poll loop can re-offer and re-resolve the same order, so
// a verdict that arrives twice is a no-op the second time. Only a pending order
// moves; an order already in a terminal state reports what it already decided,
// which is what stops a retried verdict from overwriting the first one.
func ResolveMischiefOrder(guid, status, detail string) (MischiefOrder, error) {
if !validMischiefVerdict(status) {
return MischiefOrder{}, fmt.Errorf("mischief: bad verdict %q", status)
}
now := nowUnix()
res, err := Get().Exec(
`UPDATE mischief_orders SET status = ?, detail = ?, updated_at = ?
WHERE guid = ? AND status = ?`,
status, detail, now, guid, MischiefPending,
)
if err != nil {
return MischiefOrder{}, fmt.Errorf("mischief: resolve order: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
// Either it doesn't exist or it's already terminal. Tell the caller which.
return MischiefOrderByGUID(guid)
}
return MischiefOrderByGUID(guid)
}
// MischiefOrderByGUID reads one order.
func MischiefOrderByGUID(guid string) (MischiefOrder, error) {
rows, err := Get().Query(
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
FROM mischief_orders WHERE guid = ?`, guid,
)
if err != nil {
return MischiefOrder{}, fmt.Errorf("mischief: read order: %w", err)
}
defer rows.Close()
out, err := scanMischiefOrders(rows)
if err != nil {
return MischiefOrder{}, err
}
if len(out) == 0 {
return MischiefOrder{}, ErrNoSuchOrder
}
return out[0], nil
}
// MischiefOrdersByBuyer returns a buyer's own recent orders, newest first, for
// the storefront status panel. Keyed on the OIDC subject so a username change
// doesn't strand a buyer's history.
func MischiefOrdersByBuyer(buyerSub string, limit int) ([]MischiefOrder, error) {
if limit <= 0 {
limit = 20
}
rows, err := Get().Query(
`SELECT guid, buyer_sub, buyer_username, target_token, target_name, tier, signed, status, COALESCE(detail, ''), created_at, updated_at
FROM mischief_orders
WHERE buyer_sub = ?
ORDER BY created_at DESC
LIMIT ?`,
buyerSub, limit,
)
if err != nil {
return nil, fmt.Errorf("mischief: orders by buyer: %w", err)
}
defer rows.Close()
return scanMischiefOrders(rows)
}
// CountMischiefOrdersSince counts how many orders a buyer has placed since a unix
// cutoff. It backs the storefront's burst guard — the real economic caps (per
// day, per target, boss weekly) live on gogobee, this only blunts form-spam.
func CountMischiefOrdersSince(buyerSub string, since int64) (int, error) {
var n int
err := Get().QueryRow(
`SELECT COUNT(*) FROM mischief_orders WHERE buyer_sub = ? AND created_at >= ?`,
buyerSub, since,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("mischief: count recent orders: %w", err)
}
return n, nil
}
func scanMischiefOrders(rows *sql.Rows) ([]MischiefOrder, error) {
var out []MischiefOrder
for rows.Next() {
var o MischiefOrder
var sig int
if err := rows.Scan(&o.GUID, &o.BuyerSub, &o.BuyerUsername, &o.TargetToken,
&o.TargetName, &o.Tier, &sig, &o.Status, &o.Detail, &o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, fmt.Errorf("mischief: scan order: %w", err)
}
o.Signed = sig != 0
out = append(out, o)
}
return out, rows.Err()
}
// ---- user_euro: the buyer's own advisory balance -------------------------------
// ReplaceUserEuro swaps the whole balance table for a new snapshot, in one
// transaction, mirroring the roster: a buyer gogobee stopped reporting (opted
// out, deleted character) must drop out rather than keep a frozen number forever.
// Replace — never merge.
func ReplaceUserEuro(balances []MischiefBalance, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM user_euro`); err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO user_euro (username, euro, snapshot_at) VALUES (?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, b := range balances {
if b.Username == "" {
continue
}
if _, err := stmt.Exec(b.Username, b.Euro, snapshotAt); err != nil {
return err
}
}
return tx.Commit()
}
// MischiefBalance is one buyer's advisory euro balance in the roster push.
type MischiefBalance struct {
Username string `json:"username"`
Euro float64 `json:"euro"`
}
// ---- mischief_tiers: the price catalog, pushed by gogobee -----------------------
// MischiefTier is one rung of the storefront's price list. gogobee is the sole
// authority on prices — it pushes the whole catalog on the roster tick so a fee
// retune reaches the storefront within a snapshot and Pete never hardcodes a
// number that can silently drift. Pete uses it only to render and to validate a
// submitted tier key; the real debit is always gogobee's, at its own price.
type MischiefTier struct {
Key string `json:"key"`
Display string `json:"display"`
Fee int `json:"fee"`
SignedFee int `json:"signed_fee"`
Blurb string `json:"blurb,omitempty"`
}
// ReplaceMischiefTiers swaps the whole catalog for gogobee's latest, preserving
// the push order (grunt→boss) via an ordinal column. Replace, never merge — a
// tier gogobee dropped must disappear from the storefront.
func ReplaceMischiefTiers(tiers []MischiefTier) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM mischief_tiers`); err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO mischief_tiers (key, display, fee, signed_fee, blurb, ordinal) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for i, t := range tiers {
if t.Key == "" {
continue
}
if _, err := stmt.Exec(t.Key, t.Display, t.Fee, t.SignedFee, t.Blurb, i); err != nil {
return err
}
}
return tx.Commit()
}
// MischiefTiers returns the catalog in push order. Empty (not an error) until
// gogobee has pushed one — the storefront treats that as "catalog not ready yet".
func MischiefTiers() ([]MischiefTier, error) {
rows, err := Get().Query(
`SELECT key, display, fee, signed_fee, COALESCE(blurb, '') FROM mischief_tiers ORDER BY ordinal`)
if err != nil {
return nil, fmt.Errorf("mischief: read tiers: %w", err)
}
defer rows.Close()
var out []MischiefTier
for rows.Next() {
var t MischiefTier
if err := rows.Scan(&t.Key, &t.Display, &t.Fee, &t.SignedFee, &t.Blurb); err != nil {
return nil, fmt.Errorf("mischief: scan tier: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// MischiefTierByKey looks up one tier for validating a submitted order.
func MischiefTierByKey(key string) (MischiefTier, bool, error) {
tiers, err := MischiefTiers()
if err != nil {
return MischiefTier{}, false, err
}
for _, t := range tiers {
if t.Key == key {
return t, true, nil
}
}
return MischiefTier{}, false, nil
}
// UserEuro reads one buyer's advisory balance. The bool is false when gogobee has
// never reported a balance for them (never played, opted out) — the storefront
// then shows tiers without an affordability hint rather than a misleading €0.
func UserEuro(username string) (float64, bool, error) {
var euro float64
err := Get().QueryRow(`SELECT euro FROM user_euro WHERE username = ?`, username).Scan(&euro)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("mischief: read user euro: %w", err)
}
return euro, true, nil
}

View File

@@ -0,0 +1,178 @@
package storage
import (
"errors"
"testing"
"time"
)
func TestMischiefOrderLifecycle(t *testing.T) {
setupTestDB(t)
o, err := InsertMischiefOrder("sub-1", "reala", "tok-josie", "Josie", "elite", true)
if err != nil {
t.Fatal(err)
}
if o.Status != MischiefPending {
t.Fatalf("fresh order status = %q, want pending", o.Status)
}
if !o.Signed {
t.Error("signed flag lost through insert")
}
pending, err := PendingMischiefOrders(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].GUID != o.GUID {
t.Fatalf("pending = %+v, want the one order we just placed", pending)
}
// gogobee places the contract.
got, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "the word is out")
if err != nil {
t.Fatal(err)
}
if got.Status != MischiefPlaced || got.Detail != "the word is out" {
t.Fatalf("resolved order = %+v, want placed with detail", got)
}
// It must leave the pending set.
if pending, _ := PendingMischiefOrders(10); len(pending) != 0 {
t.Fatalf("placed order still pending: %+v", pending)
}
}
// TestMischiefResolveIsIdempotent is the whole reason the guid is an end-to-end
// key: gogobee's poll loop retries, so a verdict can arrive twice, and the second
// arrival must not overwrite the first or error.
func TestMischiefResolveIsIdempotent(t *testing.T) {
setupTestDB(t)
o, err := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
if err != nil {
t.Fatal(err)
}
if _, err := ResolveMischiefOrder(o.GUID, MischiefPlaced, "first"); err != nil {
t.Fatal(err)
}
// A second, *different* verdict arrives. First one wins.
got, err := ResolveMischiefOrder(o.GUID, MischiefBouncedFunds, "second")
if err != nil {
t.Fatalf("re-resolve errored: %v", err)
}
if got.Status != MischiefPlaced || got.Detail != "first" {
t.Fatalf("idempotency broken: order became %+v", got)
}
}
func TestMischiefResolveUnknownAndBadVerdict(t *testing.T) {
setupTestDB(t)
if _, err := ResolveMischiefOrder("nope", MischiefPlaced, ""); !errors.Is(err, ErrNoSuchOrder) {
t.Fatalf("unknown guid err = %v, want ErrNoSuchOrder", err)
}
o, _ := InsertMischiefOrder("sub-1", "reala", "tok", "Josie", "grunt", false)
if _, err := ResolveMischiefOrder(o.GUID, "exploded", ""); err == nil {
t.Error("a bogus verdict status was accepted")
}
// The order must survive a rejected verdict as still-pending.
if got, _ := MischiefOrderByGUID(o.GUID); got.Status != MischiefPending {
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
}
}
func TestMischiefOrdersByBuyerAndCount(t *testing.T) {
setupTestDB(t)
for i := 0; i < 3; i++ {
if _, err := InsertMischiefOrder("sub-A", "alice", "tok", "Josie", "grunt", false); err != nil {
t.Fatal(err)
}
}
if _, err := InsertMischiefOrder("sub-B", "bob", "tok", "Josie", "grunt", false); err != nil {
t.Fatal(err)
}
mine, err := MischiefOrdersByBuyer("sub-A", 20)
if err != nil {
t.Fatal(err)
}
if len(mine) != 3 {
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
}
n, err := CountMischiefOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("count since an hour ago = %d, want 3", n)
}
if n, _ := CountMischiefOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
t.Fatalf("count since the future = %d, want 0", n)
}
}
func TestMischiefTiersReplaceAndLookup(t *testing.T) {
setupTestDB(t)
tiers := []MischiefTier{
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50, Blurb: "theatre"},
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
}
if err := ReplaceMischiefTiers(tiers); err != nil {
t.Fatal(err)
}
got, err := MischiefTiers()
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Key != "grunt" || got[1].Key != "boss" {
t.Fatalf("catalog order not preserved: %+v", got)
}
tier, ok, err := MischiefTierByKey("boss")
if err != nil || !ok || tier.SignedFee != 1500 {
t.Fatalf("lookup boss = %+v ok=%v err=%v", tier, ok, err)
}
if _, ok, _ := MischiefTierByKey("dragon"); ok {
t.Error("lookup invented a tier that was never pushed")
}
// Replace, never merge: a dropped tier vanishes.
if err := ReplaceMischiefTiers([]MischiefTier{{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50}}); err != nil {
t.Fatal(err)
}
if _, ok, _ := MischiefTierByKey("boss"); ok {
t.Error("a tier dropped from the push survived the replace")
}
}
func TestUserEuroReplaceAndRead(t *testing.T) {
setupTestDB(t)
if err := ReplaceUserEuro([]MischiefBalance{{Username: "reala", Euro: 820.5}}, time.Now().Unix()); err != nil {
t.Fatal(err)
}
euro, has, err := UserEuro("reala")
if err != nil || !has || euro != 820.5 {
t.Fatalf("UserEuro(reala) = %v has=%v err=%v", euro, has, err)
}
// A user gogobee has never reported reads as "unknown", not €0 — the
// storefront shows no hint rather than a discouraging, wrong zero.
if _, has, _ := UserEuro("stranger"); has {
t.Error("UserEuro claimed to know a stranger's balance")
}
// Replace drops anyone the new snapshot omits.
if err := ReplaceUserEuro([]MischiefBalance{{Username: "bob", Euro: 10}}, time.Now().Unix()); err != nil {
t.Fatal(err)
}
if _, has, _ := UserEuro("reala"); has {
t.Error("a balance that fell out of the snapshot survived the replace")
}
}

View File

@@ -91,6 +91,27 @@ func LoadRoster() ([]RosterEntry, int64, error) {
return out, RosterSnapshotAt(), nil
}
// RosterEntryByToken looks up one adventurer by their roster token. The bool is
// false when no such token is on the current board — which is exactly the check
// the storefront needs: a buyer may only order a hit on a mark the live board is
// actually showing, never a stale or guessed token.
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
var e RosterEntry
err := Get().QueryRow(`
SELECT token, name, level, COALESCE(class_race, ''), status,
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
FROM adventure_roster WHERE token = ?`, token).Scan(
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt)
if err == sql.ErrNoRows {
return RosterEntry{}, false, nil
}
if err != nil {
return RosterEntry{}, false, err
}
return e, true, nil
}
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
// never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot.

View File

@@ -52,6 +52,65 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
snapshot_at INTEGER NOT NULL DEFAULT 0
);
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
-- ever read for the one authenticated user asking about themselves, so the board
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
-- the storefront greys out tiers it thinks you can't afford, but the real debit
-- happens on gogobee at claim time and a stale balance just bounces an order.
CREATE TABLE IF NOT EXISTS user_euro (
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
euro REAL NOT NULL DEFAULT 0,
snapshot_at INTEGER NOT NULL DEFAULT 0
);
-- A mischief contract a buyer placed from the web storefront, on its way to
-- gogobee. Pete never touches money and never runs the game rules — it only
-- records the *intent* and later the verdict gogobee hands back. The status
-- ladder:
--
-- pending -> placed (gogobee debited the buyer and opened a contract)
-- -> bounced_funds (buyer couldn't actually afford it)
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
-- a live contract already, cooldown, cap, ...)
--
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
-- retried without charging the buyer twice or opening two contracts. buyer_sub
-- is the OIDC subject (stable across username changes) and keys "my orders";
-- buyer_username is what gogobee turns into @username:server. target_token is
-- the roster token of the mark — the same anonymous token the board renders, so
-- ordering a hit never needs the victim's real handle.
CREATE TABLE IF NOT EXISTS mischief_orders (
guid TEXT PRIMARY KEY,
buyer_sub TEXT NOT NULL,
buyer_username TEXT NOT NULL,
target_token TEXT NOT NULL,
target_name TEXT NOT NULL, -- display copy, frozen at order time
tier TEXT NOT NULL,
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
status TEXT NOT NULL, -- see the ladder above
detail TEXT, -- gogobee's human note on the verdict
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
-- The storefront price list. gogobee is the sole authority on prices and pushes
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
-- preserves the grunt->boss order the push arrived in.
CREATE TABLE IF NOT EXISTS mischief_tiers (
key TEXT PRIMARY KEY,
display TEXT NOT NULL,
fee INTEGER NOT NULL,
signed_fee INTEGER NOT NULL,
blurb TEXT,
ordinal INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,