Files
Pete/internal/web/equip.go
prosolis 1589c36e96 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.
2026-07-17 08:44:28 -07:00

257 lines
8.5 KiB
Go

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