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:
prosolis
2026-07-17 08:44:28 -07:00
parent e90deda498
commit 1589c36e96
10 changed files with 999 additions and 7 deletions

256
internal/web/equip.go Normal file
View 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
View 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
}

View File

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

View File

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

View File

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