adventure: let an owner equip and unequip from their own page
The one adventure ask that carries intent back to the game box, built the mischief way: no new network route, Pete records the equip/unequip and gogobee polls it and files a verdict. The who page grows Equip / Take off buttons on the owner's own worn and backpack panels, and a pending-changes strip that shows 'queued' until the verdict lands, never claiming a change it can't see. The item handle is the inventory row id, sent only on wearable magic items, so a non-zero id is also what gates the button. gogobee resolves the owner by localpart, never a name.
This commit is contained in:
256
internal/web/equip.go
Normal file
256
internal/web/equip.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The equip queue's web seam.
|
||||
//
|
||||
// Two audiences, same shape as mischief. A signed-in owner, on their own detail
|
||||
// page, clicks Equip or Unequip; the OIDC-gated buy half records the intent after
|
||||
// proving they own the page and the item. gogobee hits the bearer-authed pair: it
|
||||
// polls pending orders and pushes a verdict. Pete never runs an equip rule — it
|
||||
// records intent and files the verdict; the item actually moves on the game box,
|
||||
// on gogobee's next poll tick. The UI says "queued" and never claims it landed.
|
||||
|
||||
// equipBurstWindow / equipBurstMax are Pete's own anti-spam guard, nothing more.
|
||||
// The real eligibility — still-owned, wearable, the 3-bond cap — is gogobee's, at
|
||||
// verdict time. This only stops a stuck mouse button from spooling the table.
|
||||
const (
|
||||
equipBurstWindow = time.Hour
|
||||
equipBurstMax = 40
|
||||
)
|
||||
|
||||
// equipOrderReq is the browser's request. The owner names the page they're on
|
||||
// (proving ownership), what they're doing, and the item — by its inventory row id
|
||||
// for an equip, or by slot for an unequip. Pete resolves the display facts itself
|
||||
// from the owner's own detail, never trusting the client for name or slot.
|
||||
type equipOrderReq struct {
|
||||
Token string `json:"token"`
|
||||
Action string `json:"action"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
Slot string `json:"slot"`
|
||||
}
|
||||
|
||||
// handleEquipOrder places a pending equip/unequip for the signed-in owner. It
|
||||
// asserts what Pete can honestly know: the viewer is signed in, owns this exact
|
||||
// page (proven by a row gogobee pushed, never by the token alone), and the item
|
||||
// is actually in the panel they claim. Bond caps and the rest of the rulebook are
|
||||
// gogobee's, checked when it drains the order.
|
||||
func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
owner := buyerLocalpart(u)
|
||||
if owner == "" {
|
||||
writeEquipError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
|
||||
var req equipOrderReq
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeEquipError(w, http.StatusBadRequest, "no character")
|
||||
return
|
||||
}
|
||||
if req.Action != storage.EquipActionEquip && req.Action != storage.EquipActionUnequip {
|
||||
writeEquipError(w, http.StatusBadRequest, "bad action")
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership: only the localpart that owns this exact page token may dress it.
|
||||
// The detail row is gogobee's own proof of owner<->page; a token alone proves
|
||||
// nothing.
|
||||
pd, ok, err := storage.PlayerDetailByOwner(owner, req.Token)
|
||||
if err != nil {
|
||||
slog.Error("equip: owner lookup", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeEquipError(w, http.StatusForbidden, "that's not your adventurer")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the item from the owner's own panels — never from the client — so a
|
||||
// forged name or slot can't ride into the order. An equip names a backpack item
|
||||
// by its row id (present only on wearable magic items); an unequip names a worn
|
||||
// slot.
|
||||
var itemName, slot string
|
||||
if req.Action == storage.EquipActionEquip {
|
||||
it, found := findBackpackItem(pd.Inventory, req.ItemID)
|
||||
if !found {
|
||||
writeEquipError(w, http.StatusBadRequest, "that item isn't in your pack")
|
||||
return
|
||||
}
|
||||
itemName, slot = it.Name, it.Slot
|
||||
} else {
|
||||
it, found := findWornSlot(pd.Equipped, req.Slot)
|
||||
if !found {
|
||||
writeEquipError(w, http.StatusBadRequest, "nothing's in that slot")
|
||||
return
|
||||
}
|
||||
itemName, slot = it.Name, it.Slot
|
||||
}
|
||||
|
||||
since := time.Now().Add(-equipBurstWindow).Unix()
|
||||
if n, err := storage.CountEquipOrdersSince(u.Sub, since); err != nil {
|
||||
slog.Error("equip: burst count", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
} else if n >= equipBurstMax {
|
||||
writeEquipError(w, http.StatusTooManyRequests, "slow down — too many changes in a short while")
|
||||
return
|
||||
}
|
||||
|
||||
characterName := ""
|
||||
if entry, ok, err := storage.RosterEntryByToken(req.Token); err == nil && ok {
|
||||
characterName = entry.Name
|
||||
}
|
||||
|
||||
itemID := req.ItemID
|
||||
if req.Action == storage.EquipActionUnequip {
|
||||
itemID = 0 // a worn item's inventory row is gone; the slot is the handle
|
||||
}
|
||||
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action)
|
||||
if err != nil {
|
||||
slog.Error("equip: insert order", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("equip: order placed", "guid", order.GUID, "owner", owner, "action", req.Action, "slot", slot)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
// findBackpackItem finds a wearable backpack item by its row id. Only magic items
|
||||
// carry a non-zero ID, so a zero id can never match — the id both names the item
|
||||
// and gates the action to the magic-item equip path.
|
||||
func findBackpackItem(items []storage.ItemView, id int64) (storage.ItemView, bool) {
|
||||
if id == 0 {
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ID == id {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
|
||||
// findWornSlot finds a worn item by the slot it fills.
|
||||
func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool) {
|
||||
if slot == "" {
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Slot == slot {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
|
||||
// handleEquipOrders returns the signed-in owner's own recent equip orders for the
|
||||
// status strip, newest first. Scoped to their OIDC subject.
|
||||
func (s *Server) handleEquipOrders(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
orders, err := storage.EquipOrdersByOwner(u.Sub, 20)
|
||||
if err != nil {
|
||||
slog.Error("equip: orders by owner", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.EquipOrder{}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||
|
||||
// equipPollLimit caps one poll, matching the mischief seam.
|
||||
const equipPollLimit = 50
|
||||
|
||||
// handleEquipPending is gogobee's poll: every order still waiting. Like mischief
|
||||
// there is no stale-reoffer window — a gogobee that dies mid-apply leaves the order
|
||||
// pending to be offered again, and gogobee's guid guard makes the replay a no-op.
|
||||
func (s *Server) handleEquipPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orders, err := storage.PendingEquipOrders(equipPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("equip: pending", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.EquipOrder{}
|
||||
}
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// equipVerdict is gogobee's answer on an order: the terminal status and a human
|
||||
// note to render.
|
||||
type equipVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// handleEquipVerdict 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 moves the order. An unknown guid is a 400 — under the seam's
|
||||
// contract that parks the row for a human rather than retrying forever against a
|
||||
// row that will never exist.
|
||||
func (s *Server) handleEquipVerdict(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v equipVerdict
|
||||
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.ResolveEquipOrder(v.GUID, v.Status, v.Detail)
|
||||
if errors.Is(err, storage.ErrNoSuchEquipOrder) {
|
||||
slog.Error("equip: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||
http.Error(w, "no such order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("equip: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slog.Info("equip: order resolved", "guid", order.GUID, "status", order.Status)
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
func writeEquipError(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})
|
||||
}
|
||||
199
internal/web/equip_test.go
Normal file
199
internal/web/equip_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The equip queue's web seam. Two contracts: the owner half proves ownership and
|
||||
// resolves the item from Pete's own record (never the client), and the gogobee
|
||||
// half is a bearer-authed, idempotent pending/verdict pair.
|
||||
|
||||
// seedEquip stands up a board + a private detail set owned by `owner`, with a
|
||||
// wearable backpack magic item (ID != 0, the equip handle) and a worn item in a
|
||||
// slot. Mirrors seedWho but pins the fields the equip path keys on.
|
||||
func seedEquip(t *testing.T, owner string) *Server {
|
||||
t.Helper()
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner,
|
||||
Token: "tok-josie",
|
||||
Inventory: []storage.ItemView{
|
||||
// A wearable magic item: carries an ID, so it can be equipped.
|
||||
{ID: 501, Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||
Slot: "ring_1", Attunement: true, Effect: "-8% damage taken"},
|
||||
// Mundane gear: no ID, so no equip handle even though it has a slot.
|
||||
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||
Slot: "weapon", SkillSource: "mining"},
|
||||
},
|
||||
Equipped: []storage.ItemView{
|
||||
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
||||
Effect: "faster to act", Attunement: true, Attuned: true},
|
||||
},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func placeEquip(t *testing.T, s *Server, username string, req equipOrderReq) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "POST", "/api/equip/order", req)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipOrder(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestEquipOrderHappyEquip: the owner equips a backpack magic item; Pete resolves
|
||||
// the item name and slot from its own record and queues a pending order.
|
||||
func TestEquipOrderHappyEquip(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("equip = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var o storage.EquipOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != storage.EquipPending || o.Action != "equip" || o.ItemID != 501 {
|
||||
t.Fatalf("order = %+v", o)
|
||||
}
|
||||
// Name and slot come from Pete's own detail, not the request.
|
||||
if o.ItemName != "Ring of Protection" || o.Slot != "ring_1" || o.CharacterName != "Josie" {
|
||||
t.Fatalf("order didn't resolve from the owner's record: %+v", o)
|
||||
}
|
||||
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 1 {
|
||||
t.Fatal("order didn't land in the pending set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipOrderHappyUnequip: taking off a worn item rides on the slot; item_id 0.
|
||||
func TestEquipOrderHappyUnequip(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "cloak"})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("unequip = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var o storage.EquipOrder
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &o)
|
||||
if o.Action != "unequip" || o.Slot != "cloak" || o.ItemName != "Cloak of Elvenkind" || o.ItemID != 0 {
|
||||
t.Fatalf("unequip order = %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipOrderRejections: the honest failure surface — not your page, item not
|
||||
// in the pack, empty slot, unequippable mundane gear, bad action.
|
||||
func TestEquipOrderRejections(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
// A different signed-in user does not own Josie's page.
|
||||
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 403 {
|
||||
t.Errorf("non-owner equip = %d, want 403", w.Code)
|
||||
}
|
||||
// An item id that isn't in the pack.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 999}); w.Code != 400 {
|
||||
t.Errorf("unknown item = %d, want 400", w.Code)
|
||||
}
|
||||
// Mundane gear has a slot but no id, so it can never be named for equip.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 0}); w.Code != 400 {
|
||||
t.Errorf("no-id equip = %d, want 400", w.Code)
|
||||
}
|
||||
// Unequip of a slot nothing is in.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "boots"}); w.Code != 400 {
|
||||
t.Errorf("empty-slot unequip = %d, want 400", w.Code)
|
||||
}
|
||||
// A bogus action.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "wield", ItemID: 501}); w.Code != 400 {
|
||||
t.Errorf("bad action = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
|
||||
// never nulls, and files a verdict once.
|
||||
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 200 {
|
||||
t.Fatalf("seed order = %d", w.Code)
|
||||
}
|
||||
// The owner sees their own order in the "my orders" strip.
|
||||
if rows := mustEquipOrders(t, s, "reala"); len(rows) != 1 {
|
||||
t.Fatalf("owner sees %d orders, want 1", len(rows))
|
||||
}
|
||||
|
||||
// No bearer → 401 on both machine endpoints.
|
||||
if w := httptest.NewRecorder(); func() bool {
|
||||
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "", nil))
|
||||
return w.Code == 401
|
||||
}() == false {
|
||||
t.Error("pending without bearer should be 401")
|
||||
}
|
||||
|
||||
// Pending returns the order (bearer-authed), never null.
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "tok", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("pending = %d", w.Code)
|
||||
}
|
||||
var pending []storage.EquipOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil || len(pending) != 1 {
|
||||
t.Fatalf("pending body = %s err=%v", w.Body.String(), err)
|
||||
}
|
||||
guid := pending[0].GUID
|
||||
|
||||
// A verdict resolves it; a replay is a no-op.
|
||||
verdict := func(status, detail string) *httptest.ResponseRecorder {
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||
equipVerdict{GUID: guid, Status: status, Detail: detail}))
|
||||
return rw
|
||||
}
|
||||
if w := verdict("applied", "worn"); w.Code != 200 {
|
||||
t.Fatalf("verdict = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := verdict("rejected_not_owned", "too late"); w.Code != 200 {
|
||||
t.Fatalf("replay verdict = %d", w.Code)
|
||||
}
|
||||
got, _ := storage.EquipOrderByGUID(guid)
|
||||
if got.Status != storage.EquipApplied || got.Detail != "worn" {
|
||||
t.Fatalf("replay overwrote the first verdict: %+v", got)
|
||||
}
|
||||
|
||||
// Unknown guid parks with a 400, not a silent retry.
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||
equipVerdict{GUID: "ghost", Status: "applied"}))
|
||||
if rw.Code != 400 {
|
||||
t.Errorf("unknown guid = %d, want 400", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// mustEquipOrders returns the raw "my orders" JSON rows for a user. Small helper
|
||||
// so the wire test can pull the guid it just created without reaching into storage.
|
||||
func mustEquipOrders(t *testing.T, s *Server, username string) []json.RawMessage {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "GET", "/api/equip/orders", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipOrders(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("orders = %d", w.Code)
|
||||
}
|
||||
var rows []json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -255,6 +255,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||
|
||||
// The equip queue's game-box wire: gogobee polls pending equip/unequip orders
|
||||
// and pushes a verdict. Same bearer token and same reason as the pair above —
|
||||
// the caller is a machine on the tailnet. The owner-facing half hangs off the
|
||||
// auth block below.
|
||||
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||
|
||||
// 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.
|
||||
@@ -282,6 +289,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||
|
||||
// The equip queue, owner side. Signed-in only — an order dresses a
|
||||
// specific owner's character — and gated on the adventure seam like the
|
||||
// storefront, since without a board there is no detail page to equip from.
|
||||
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||
}
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,12 @@
|
||||
{{end}}
|
||||
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
||||
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
||||
{{if .EquipAction}}
|
||||
<div class="mt-1.5">
|
||||
<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||
data-action="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
@@ -274,7 +280,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
{{if .Worn}}
|
||||
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
||||
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
||||
@@ -299,6 +305,14 @@
|
||||
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<!-- Changes you've asked for. The queue is honest: an equip lands on the
|
||||
game box's next poll, so a fresh order reads "queued", never "done".
|
||||
JS fills this from /api/equip/orders. -->
|
||||
<div id="equip-orders" class="mt-6 hidden">
|
||||
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
|
||||
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -363,5 +377,102 @@
|
||||
}
|
||||
timer = setInterval(refresh, 60000);
|
||||
})();
|
||||
|
||||
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
|
||||
// item actually moves on the game box's next poll. So the UI never claims a change
|
||||
// landed — it shows "queued" and lets the order's own status be the truth.
|
||||
(function () {
|
||||
var panel = document.getElementById('gear-panel');
|
||||
if (!panel) return; // only the owner gets this panel at all
|
||||
var token = panel.getAttribute('data-token');
|
||||
var box = document.getElementById('equip-orders');
|
||||
var list = document.getElementById('equip-orders-list');
|
||||
|
||||
// How each terminal status reads to the owner. gogobee only rejects when the
|
||||
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||||
// is still an applied change, and its detail line says so.
|
||||
var STATUS = {
|
||||
pending: 'queued',
|
||||
applied: 'done',
|
||||
rejected_not_owned: "couldn't — that item had already moved",
|
||||
rejected_not_worn: "couldn't — that slot was already empty",
|
||||
rejected_not_equippable: "couldn't — that item can't be worn"
|
||||
};
|
||||
|
||||
var pollTimer = null;
|
||||
|
||||
function render(orders) {
|
||||
list.innerHTML = '';
|
||||
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||
box.classList.remove('hidden');
|
||||
var anyPending = false;
|
||||
orders.forEach(function (o) {
|
||||
if (o.status === 'pending') anyPending = true;
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-baseline justify-between gap-3';
|
||||
var verb = o.action === 'equip' ? 'Equip' : 'Take off';
|
||||
var left = document.createElement('span');
|
||||
left.className = 'flex-1';
|
||||
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
|
||||
var right = document.createElement('span');
|
||||
right.className = 'shrink-0 ' + (o.status === 'pending'
|
||||
? 'text-[color:var(--ink)]/45'
|
||||
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||
right.textContent = o.detail || STATUS[o.status] || o.status;
|
||||
li.appendChild(left); li.appendChild(right);
|
||||
list.appendChild(li);
|
||||
});
|
||||
// While anything is still queued, keep refreshing so the verdict lands without
|
||||
// a reload; stop once everything is terminal.
|
||||
if (anyPending && !pollTimer) {
|
||||
pollTimer = setInterval(loadOrders, 15000);
|
||||
} else if (!anyPending && pollTimer) {
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (o) { if (o) render(o); })
|
||||
.catch(function () { /* transient — a later tick will do */ });
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.equip-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
btn.textContent = 'queuing…';
|
||||
fetch('/api/equip/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
action: btn.getAttribute('data-action'),
|
||||
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||||
slot: btn.getAttribute('data-slot') || ''
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||
return;
|
||||
}
|
||||
btn.textContent = 'queued';
|
||||
loadOrders();
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = 'try again';
|
||||
});
|
||||
});
|
||||
|
||||
loadOrders();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -111,15 +111,35 @@ type whoPage struct {
|
||||
type itemRow struct {
|
||||
storage.ItemView
|
||||
Worn bool
|
||||
// EquipAction is the control this row offers its owner: "unequip" on anything
|
||||
// worn, "equip" on a backpack item the magic-item path will accept, "" on
|
||||
// everything else (vault items, mundane backpack gear). Empty means no button.
|
||||
EquipAction string
|
||||
}
|
||||
|
||||
func itemRows(items []storage.ItemView, worn bool) []itemRow {
|
||||
// itemRows wraps gogobee's item views for one panel. panel is "worn", "backpack",
|
||||
// or "vault"; it decides both the bond wording (only a worn item can be inert) and
|
||||
// which equip control, if any, the row offers.
|
||||
func itemRows(items []storage.ItemView, panel string) []itemRow {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
worn := panel == "worn"
|
||||
out := make([]itemRow, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, itemRow{ItemView: it, Worn: worn})
|
||||
row := itemRow{ItemView: it, Worn: worn}
|
||||
switch {
|
||||
case worn:
|
||||
// Everything in magic_item_equipped is a magic item and can come off; the
|
||||
// slot is the handle gogobee unequips by.
|
||||
row.EquipAction = storage.EquipActionUnequip
|
||||
case panel == "backpack" && it.ID != 0:
|
||||
// Only a wearable magic item carries a row id (gogobee sets it just in the
|
||||
// magic-item branch), so the id gates Equip to exactly the items the
|
||||
// magic-item path accepts — never mundane gear, never a vault item.
|
||||
row.EquipAction = storage.EquipActionEquip
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -204,9 +224,9 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
||||
page.HasSelf = true
|
||||
page.Self = self
|
||||
page.Worn = itemRows(self.Equipped, true)
|
||||
page.Backpack = itemRows(self.Inventory, false)
|
||||
page.VaultRows = itemRows(self.Vault, false)
|
||||
page.Worn = itemRows(self.Equipped, "worn")
|
||||
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||
page.VaultRows = itemRows(self.Vault, "vault")
|
||||
for _, it := range self.Equipped {
|
||||
if it.Attuned {
|
||||
page.BondsUsed++
|
||||
|
||||
Reference in New Issue
Block a user