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:
250
internal/web/mischief.go
Normal file
250
internal/web/mischief.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The mischief storefront's web seam.
|
||||
//
|
||||
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
|
||||
// endpoints: a signed-in buyer picks a mark off the anonymous board and places a
|
||||
// hit, and reads back their own orders' standing. gogobee hits the bearer-authed
|
||||
// pair: it polls pending orders and pushes a verdict, exactly like the escrow
|
||||
// seam in games.go and under the same standing rule — Pete never calls gogobee,
|
||||
// never moves money, never runs a game rule. It records intent and files a
|
||||
// verdict; everything real happens on the game box.
|
||||
|
||||
// mischiefBurstWindow / mischiefBurstMax are Pete's own anti-spam guard, and
|
||||
// nothing more. The real economic caps — two contracts a day, one live per mark,
|
||||
// a boss a week — are gogobee's and are enforced at claim time. This only stops a
|
||||
// signed-in buyer from spooling the orders table with a stuck mouse button.
|
||||
const (
|
||||
mischiefBurstWindow = time.Hour
|
||||
mischiefBurstMax = 20
|
||||
)
|
||||
|
||||
// mischiefOrderReq is the browser's buy request. Price and eligibility are not
|
||||
// the buyer's to assert: they send a tier key and a mark, never a number.
|
||||
type mischiefOrderReq struct {
|
||||
TargetToken string `json:"target_token"`
|
||||
Tier string `json:"tier"`
|
||||
Signed bool `json:"signed"`
|
||||
}
|
||||
|
||||
// handleMischiefOrder places a pending order for the signed-in buyer. It asserts
|
||||
// only what Pete can honestly know: the buyer is signed in with a username the
|
||||
// game box can resolve, the tier is one gogobee currently offers, and the mark is
|
||||
// actually on the live board. Money and the full rulebook are gogobee's, checked
|
||||
// when it claims the order.
|
||||
func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
// A session minted before the storefront existed carries no username, and
|
||||
// without one gogobee can't name the buyer to its ledger. Send them back
|
||||
// through sign-in to mint a fresh one rather than place an unfulfillable order.
|
||||
if u.Username == "" {
|
||||
writeMischiefError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
|
||||
var req mischiefOrderReq
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.TargetToken == "" {
|
||||
writeMischiefError(w, http.StatusBadRequest, "no target")
|
||||
return
|
||||
}
|
||||
|
||||
tier, ok, err := storage.MischiefTierByKey(req.Tier)
|
||||
if err != nil {
|
||||
slog.Error("mischief: tier lookup", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeMischiefError(w, http.StatusBadRequest, "no such tier")
|
||||
return
|
||||
}
|
||||
|
||||
mark, ok, err := storage.RosterEntryByToken(req.TargetToken)
|
||||
if err != nil {
|
||||
slog.Error("mischief: target lookup", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
// The board moved on, or the token was never real. Either way there is no
|
||||
// one to send trouble to.
|
||||
writeMischiefError(w, http.StatusNotFound, "that adventurer is no longer on the board")
|
||||
return
|
||||
}
|
||||
|
||||
// Burst guard. Keyed on the OIDC subject so a username change can't reset it.
|
||||
since := time.Now().Add(-mischiefBurstWindow).Unix()
|
||||
if n, err := storage.CountMischiefOrdersSince(u.Sub, since); err != nil {
|
||||
slog.Error("mischief: burst count", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
} else if n >= mischiefBurstMax {
|
||||
writeMischiefError(w, http.StatusTooManyRequests, "slow down — too many orders in a short while")
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.InsertMischiefOrder(u.Sub, u.Username, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||
if err != nil {
|
||||
slog.Error("mischief: insert order", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", u.Username, "tier", tier.Key, "signed", req.Signed)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
// handleMischiefOrders returns the signed-in buyer's own orders for the status
|
||||
// panel, newest first. Scoped to their OIDC subject — a buyer sees their history
|
||||
// and no one else's.
|
||||
func (s *Server) handleMischiefOrders(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
orders, err := storage.MischiefOrdersByBuyer(u.Sub, 20)
|
||||
if err != nil {
|
||||
slog.Error("mischief: orders by buyer", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.MischiefOrder{}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// mischiefCatalogResp is what the storefront reads to draw itself: the live price
|
||||
// list plus the signed-in buyer's own advisory balance, so it can grey out tiers
|
||||
// they plainly can't afford. HasBalance distinguishes "gogobee says €0" from
|
||||
// "gogobee has never mentioned this buyer" — the latter shows no affordability
|
||||
// hint rather than a discouraging, possibly-wrong zero.
|
||||
type mischiefCatalogResp struct {
|
||||
Tiers []storage.MischiefTier `json:"tiers"`
|
||||
Euro float64 `json:"euro"`
|
||||
HasBalance bool `json:"has_balance"`
|
||||
}
|
||||
|
||||
// handleMischiefCatalog serves the price list and the buyer's balance together.
|
||||
func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
tiers, err := storage.MischiefTiers()
|
||||
if err != nil {
|
||||
slog.Error("mischief: catalog", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if tiers == nil {
|
||||
tiers = []storage.MischiefTier{}
|
||||
}
|
||||
resp := mischiefCatalogResp{Tiers: tiers}
|
||||
if u.Username != "" {
|
||||
euro, has, err := storage.UserEuro(u.Username)
|
||||
if err != nil {
|
||||
slog.Error("mischief: balance", "err", err)
|
||||
} else {
|
||||
resp.Euro, resp.HasBalance = euro, has
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||
|
||||
// handleMischiefPending is gogobee's poll: every order still waiting to be acted
|
||||
// on. A pending order carries no intermediate state, so unlike escrow there is no
|
||||
// stale-reoffer window — a gogobee that dies mid-claim simply leaves the order
|
||||
// pending to be offered again, and the guid makes the replay a no-op.
|
||||
func (s *Server) handleMischiefPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orders, err := storage.PendingMischiefOrders(mischiefPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("mischief: pending", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.MischiefOrder{}
|
||||
}
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// mischiefPollLimit caps one poll, matching the escrow seam.
|
||||
const mischiefPollLimit = 50
|
||||
|
||||
// mischiefVerdict is gogobee's answer on a claimed order: the terminal status and
|
||||
// a human note to render.
|
||||
type mischiefVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// handleMischiefClaim files gogobee's verdict against a pending order. Idempotent:
|
||||
// gogobee's poll loop retries, so the same verdict can arrive more than once and
|
||||
// only the first one moves the order. An unknown guid is a 400 — by this point
|
||||
// gogobee has already debited the buyer for an order Pete has no record of, and
|
||||
// under the seam's contract a 400 parks the row for a human rather than retrying
|
||||
// forever against a row that will never exist.
|
||||
func (s *Server) handleMischiefClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v mischiefVerdict
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.ResolveMischiefOrder(v.GUID, v.Status, v.Detail)
|
||||
if errors.Is(err, storage.ErrNoSuchOrder) {
|
||||
slog.Error("mischief: verdict for an order we have never heard of — "+
|
||||
"gogobee may have debited a buyer for it", "guid", v.GUID, "status", v.Status)
|
||||
http.Error(w, "no such order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// A malformed verdict status lands here — also a contract mismatch, so 400.
|
||||
slog.Error("mischief: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slog.Info("mischief: order resolved", "guid", order.GUID, "status", order.Status)
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
func writeMischiefError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
222
internal/web/mischief_test.go
Normal file
222
internal/web/mischief_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// newStore is a server with the adventure seam on, a signed-in buyer, and a
|
||||
// board + catalog already pushed. Auth is built by hand for the same reason the
|
||||
// casino tests do it: the OIDC handshake is a network call and none of what's
|
||||
// under test is about it.
|
||||
func newStore(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
|
||||
now := time.Now().Unix()
|
||||
push := rosterPush{
|
||||
SnapshotAt: now,
|
||||
Adventurers: []storage.RosterEntry{entry("tok-josie", "Josie", "expedition", "holymachina")},
|
||||
Tiers: []storage.MischiefTier{
|
||||
{Key: "grunt", Display: "Grunt", Fee: 40, SignedFee: 50},
|
||||
{Key: "boss", Display: "Boss", Fee: 1200, SignedFee: 1500},
|
||||
},
|
||||
Balances: []storage.MischiefBalance{{Username: "reala", Euro: 500}},
|
||||
}
|
||||
if w := postRoster(t, s, "tok", push); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// jsonReq builds a bearer-authed (or unauthed, empty token) machine request.
|
||||
func jsonReq(t *testing.T, method, path, token string, body any) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest(method, path, &buf)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func placeOrder(t *testing.T, s *Server, username string, req mischiefOrderReq) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "POST", "/api/mischief/order", req)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefOrder(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestMischiefOrderHappyPath: a signed-in buyer names a mark on the board and a
|
||||
// tier gogobee offers, and gets a pending order back.
|
||||
func TestMischiefOrderHappyPath(t *testing.T) {
|
||||
s := newStore(t)
|
||||
|
||||
w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt", Signed: false})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("order = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var o storage.MischiefOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != storage.MischiefPending || o.BuyerUsername != "reala" || o.TargetName != "Josie" {
|
||||
t.Fatalf("order = %+v", o)
|
||||
}
|
||||
if pending, _ := storage.PendingMischiefOrders(10); len(pending) != 1 {
|
||||
t.Fatalf("order didn't land in the pending set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefOrderRejections(t *testing.T) {
|
||||
s := newStore(t)
|
||||
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "dragon"}); w.Code != 400 {
|
||||
t.Errorf("unknown tier = %d, want 400", w.Code)
|
||||
}
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "ghost", Tier: "grunt"}); w.Code != 404 {
|
||||
t.Errorf("off-board target = %d, want 404", w.Code)
|
||||
}
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{Tier: "grunt"}); w.Code != 400 {
|
||||
t.Errorf("empty target = %d, want 400", w.Code)
|
||||
}
|
||||
// Signed in, but no username the ledger can name (a pre-storefront session).
|
||||
if w := placeOrder(t, s, "", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 409 {
|
||||
t.Errorf("usernameless session = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefOrderBurstGuard(t *testing.T) {
|
||||
s := newStore(t)
|
||||
for i := 0; i < mischiefBurstMax; i++ {
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
|
||||
t.Fatalf("order %d = %d, want 200", i, w.Code)
|
||||
}
|
||||
}
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 429 {
|
||||
t.Fatalf("over-cap order = %d, want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefOrderRequiresAuth(t *testing.T) {
|
||||
s := newStore(t)
|
||||
r := httptest.NewRequest("POST", "/api/mischief/order", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefOrder(w, r)
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("anonymous order = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefCatalogCarriesBalance(t *testing.T) {
|
||||
s := newStore(t)
|
||||
r := as(t, s, "reala", "GET", "/api/mischief/catalog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefCatalog(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("catalog = %d", w.Code)
|
||||
}
|
||||
var resp mischiefCatalogResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Tiers) != 2 || !resp.HasBalance || resp.Euro != 500 {
|
||||
t.Fatalf("catalog resp = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bearer wire -----------------------------------------------------------
|
||||
|
||||
func TestMischiefPendingAndClaimBearer(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "boss", Signed: true}); w.Code != 200 {
|
||||
t.Fatalf("seed order = %d", w.Code)
|
||||
}
|
||||
|
||||
// Missing bearer is rejected.
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "", nil))
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("no-bearer pending = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll returns the order.
|
||||
var pending []storage.MischiefOrder
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("pending = %d", w.Code)
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("pending count = %d, want 1", len(pending))
|
||||
}
|
||||
guid := pending[0].GUID
|
||||
|
||||
// Claim it placed.
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||
mischiefVerdict{GUID: guid, Status: storage.MischiefPlaced, Detail: "out for delivery"}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("claim = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if got, _ := storage.MischiefOrderByGUID(guid); got.Status != storage.MischiefPlaced {
|
||||
t.Fatalf("order status after claim = %q", got.Status)
|
||||
}
|
||||
|
||||
// And it's gone from the pending set.
|
||||
{
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefPending(w, jsonReq(t, "GET", "/api/mischief/pending", "tok", nil))
|
||||
var again []storage.MischiefOrder
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &again)
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("placed order still polled: %d", len(again))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefClaimUnknownGUIDis400(t *testing.T) {
|
||||
s := newStore(t)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||
mischiefVerdict{GUID: "nope", Status: storage.MischiefPlaced}))
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("unknown-guid claim = %d, want 400 (so gogobee parks it)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMischiefClaimBadVerdictIs400(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if w := placeOrder(t, s, "reala", mischiefOrderReq{TargetToken: "tok-josie", Tier: "grunt"}); w.Code != 200 {
|
||||
t.Fatal("seed")
|
||||
}
|
||||
pending, _ := storage.PendingMischiefOrders(10)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleMischiefClaim(w, jsonReq(t, "POST", "/api/mischief/claim", "tok",
|
||||
mischiefVerdict{GUID: pending[0].GUID, Status: "exploded"}))
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("bad-verdict claim = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -236,6 +236,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||
|
||||
// The mischief storefront's game-box wire: gogobee polls pending orders and
|
||||
// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
|
||||
// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
|
||||
// hangs off the auth block below.
|
||||
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||
|
||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||
@@ -254,6 +261,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/state", s.handleState)
|
||||
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
||||
mux.HandleFunc("GET /for-you", s.handleForYou)
|
||||
|
||||
// The mischief storefront, buyer side. Signed-in only — an order names a
|
||||
// buyer to gogobee's ledger — so like the casino it hangs off the auth
|
||||
// block. Only registered when the adventure seam is on; otherwise there is
|
||||
// no board to order a hit from.
|
||||
if s.adv.Enabled {
|
||||
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||
}
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
||||
|
||||
@@ -25,19 +25,62 @@
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||||
{{range .Roster}}
|
||||
<li class="flex items-center gap-4 px-5 py-3">
|
||||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||
<span class="font-semibold">{{.Name}}</span>
|
||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||
</span>
|
||||
{{if and $.User .OnRun}}
|
||||
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||||
{{end}}
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{if not .User}}
|
||||
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
|
||||
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
{{if .User}}
|
||||
<!-- Your open contracts. Rendered from filed facts, never from live game state:
|
||||
Pete only knows what gogobee told it happened. -->
|
||||
<div id="mischief-orders" class="mt-4 hidden">
|
||||
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
|
||||
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
|
||||
</div>
|
||||
|
||||
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
|
||||
nothing here asserts a number of its own. -->
|
||||
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||||
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
|
||||
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
|
||||
<div id="mischief-tiers" class="space-y-2"></div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
|
||||
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
|
||||
</label>
|
||||
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
|
||||
<p id="mischief-result" class="text-sm hidden"></p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
|
||||
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
|
||||
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
@@ -49,6 +92,8 @@
|
||||
var card = document.getElementById('roster-card');
|
||||
if (!list) return;
|
||||
|
||||
var signedIn = {{if .User}}true{{else}}false{{end}};
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
@@ -57,12 +102,15 @@
|
||||
|
||||
function row(a) {
|
||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
||||
var button = (signedIn && a.OnRun)
|
||||
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||||
: '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||
esc(a.Where) + idle + '</span></li>';
|
||||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -84,7 +132,157 @@
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
|
||||
if (signedIn) initMischief(list, esc);
|
||||
})();
|
||||
|
||||
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
|
||||
// authoritative about is the buyer's intent — every price, every yes/no, and the
|
||||
// buyer's actual balance are gogobee's, reached through the API.
|
||||
function initMischief(list, esc) {
|
||||
var modal = document.getElementById('mischief-modal');
|
||||
if (!modal) return;
|
||||
var targetEl = document.getElementById('mischief-target');
|
||||
var tiersEl = document.getElementById('mischief-tiers');
|
||||
var signedEl = document.getElementById('mischief-signed');
|
||||
var balanceEl = document.getElementById('mischief-balance');
|
||||
var resultEl = document.getElementById('mischief-result');
|
||||
var confirmEl = document.getElementById('mischief-confirm');
|
||||
var ordersWrap = document.getElementById('mischief-orders');
|
||||
var ordersList = document.getElementById('mischief-orders-list');
|
||||
|
||||
var catalog = null; // {tiers, euro, has_balance}
|
||||
var chosen = null; // selected tier key
|
||||
var current = null; // {token, name}
|
||||
|
||||
function euros(n) { return '€' + Number(n).toLocaleString(); }
|
||||
|
||||
function loadCatalog() {
|
||||
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (c) { catalog = c; return c; });
|
||||
}
|
||||
|
||||
function drawTiers() {
|
||||
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
|
||||
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
|
||||
return;
|
||||
}
|
||||
var signed = signedEl.checked;
|
||||
var known = catalog.has_balance;
|
||||
var euro = catalog.euro;
|
||||
tiersEl.innerHTML = catalog.tiers.map(function (t) {
|
||||
var price = signed ? t.signed_fee : t.fee;
|
||||
var tooDear = known && euro < price;
|
||||
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
|
||||
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
|
||||
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
|
||||
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
|
||||
'<span class="font-semibold">' + euros(price) + '</span></span>' +
|
||||
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
|
||||
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
chosen = b.getAttribute('data-key');
|
||||
confirmEl.disabled = false;
|
||||
drawTiers();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function open(token, name) {
|
||||
current = { token: token, name: name };
|
||||
chosen = null;
|
||||
confirmEl.disabled = true;
|
||||
resultEl.classList.add('hidden');
|
||||
resultEl.textContent = '';
|
||||
signedEl.checked = false;
|
||||
targetEl.textContent = name;
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
function badge(statusText) {
|
||||
var map = {
|
||||
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
|
||||
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
|
||||
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
|
||||
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
|
||||
};
|
||||
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
|
||||
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (orders) {
|
||||
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
|
||||
ordersWrap.classList.remove('hidden');
|
||||
ordersList.innerHTML = orders.map(function (o) {
|
||||
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
|
||||
return '<li class="flex items-center gap-3">' +
|
||||
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
|
||||
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
|
||||
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
list.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.mischief-send');
|
||||
if (!b) return;
|
||||
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
|
||||
});
|
||||
signedEl.addEventListener('change', drawTiers);
|
||||
document.getElementById('mischief-close').addEventListener('click', close);
|
||||
document.getElementById('mischief-cancel').addEventListener('click', close);
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
||||
|
||||
confirmEl.addEventListener('click', function () {
|
||||
if (!current || !chosen) return;
|
||||
confirmEl.disabled = true;
|
||||
resultEl.classList.add('hidden');
|
||||
fetch('/api/mischief/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
||||
.then(function (res) {
|
||||
resultEl.classList.remove('hidden');
|
||||
if (res.ok) {
|
||||
resultEl.className = 'text-sm text-red-600 font-semibold';
|
||||
resultEl.textContent = 'The word is out. Watch the board.';
|
||||
catalog = null; // balance moved; refetch next open
|
||||
loadOrders();
|
||||
setTimeout(close, 1200);
|
||||
} else {
|
||||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
|
||||
confirmEl.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
resultEl.classList.remove('hidden');
|
||||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||||
resultEl.textContent = 'The line dropped. Try again.';
|
||||
confirmEl.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
loadOrders();
|
||||
setInterval(loadOrders, 60000);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user