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:
336
internal/storage/mischief.go
Normal file
336
internal/storage/mischief.go
Normal 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
|
||||
}
|
||||
178
internal/storage/mischief_test.go
Normal file
178
internal/storage/mischief_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user