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

250
internal/web/mischief.go Normal file
View 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})
}

View 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)
}
}

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,

View File

@@ -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)

View File

@@ -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}}