Extends equip-from-the-web (ask 5, magic-only) to all five standard gear slots. Owners get an Equipment panel on their own who page with: - Take off for worn masterwork/arena pieces (round-trippable to pack) - Upgrade to the next shop tier (spends euros, confirm-gated) - Repair a damaged slot (spends euros, confirm-gated) The public Gear panel is hidden for the owner since this supersedes it. Wire: equip_orders gains a tier column; new actions upgrade/repair; new verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier. PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots server-side and rejects a client-forged tier (409), same as ask 5 trusts only Pete's own record. Verified: full suite green, headless render of the panel + confirm dialog in both day and night phases. gogobee ships the poll-apply half separately; Pete deploys first so its ingest accepts the new verdicts before gogobee emits them.
308 lines
11 KiB
Go
308 lines
11 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"`
|
|
Tier int `json:"tier"` // upgrade only: the target standard tier; verified against the pushed slot view
|
|
}
|
|
|
|
// 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
|
|
}
|
|
switch req.Action {
|
|
case storage.EquipActionEquip, storage.EquipActionUnequip,
|
|
storage.EquipActionUpgrade, storage.EquipActionRepair:
|
|
default:
|
|
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 every fact of the order from the owner's own pushed detail — never
|
|
// from the client — so a forged name, slot, tier, or price can't ride in. An
|
|
// equip names a backpack item by its row id; an unequip/take-off names a worn
|
|
// slot (a magic DnD slot in Equipped, or a masterwork/arena standard slot in
|
|
// Slots); upgrade and repair name a standard slot in Slots and move money, so
|
|
// the target tier is trusted only when it matches the slot's pushed NextTier.
|
|
var (
|
|
itemName string
|
|
slot string
|
|
itemID int64
|
|
tier int
|
|
)
|
|
switch req.Action {
|
|
case 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, itemID = it.Name, it.Slot, req.ItemID
|
|
case storage.EquipActionUnequip:
|
|
// Magic take-off keys on a DnD slot in Equipped; masterwork/arena take-off
|
|
// keys on a standard slot in Slots (CanTakeOff). The vocabularies are disjoint,
|
|
// so try each — gogobee disambiguates the same way. A plain shop-tier slot has
|
|
// nothing to round-trip, so it never resolves here (revert is an upgrade path).
|
|
if it, found := findWornSlot(pd.Equipped, req.Slot); found {
|
|
itemName, slot = it.Name, it.Slot
|
|
} else if sv, found := findSlotView(pd.Slots, req.Slot); found && sv.CanTakeOff {
|
|
itemName, slot = sv.Name, sv.Slot
|
|
} else {
|
|
writeEquipError(w, http.StatusBadRequest, "nothing to take off there")
|
|
return
|
|
}
|
|
case storage.EquipActionUpgrade:
|
|
sv, found := findSlotView(pd.Slots, req.Slot)
|
|
if !found || sv.NextTier == 0 {
|
|
writeEquipError(w, http.StatusBadRequest, "no upgrade available for that slot")
|
|
return
|
|
}
|
|
if req.Tier != sv.NextTier {
|
|
// The web offers the next tier only; a request for anything else is a stale
|
|
// page or a forged jump. Refuse rather than debit for a tier the owner never
|
|
// saw priced.
|
|
writeEquipError(w, http.StatusConflict, "that upgrade is out of date — reload the page")
|
|
return
|
|
}
|
|
itemName, slot, tier = sv.NextName, sv.Slot, sv.NextTier
|
|
case storage.EquipActionRepair:
|
|
sv, found := findSlotView(pd.Slots, req.Slot)
|
|
if !found || sv.RepairCost == 0 {
|
|
writeEquipError(w, http.StatusBadRequest, "nothing to repair there")
|
|
return
|
|
}
|
|
itemName, slot = sv.Name, sv.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
|
|
}
|
|
|
|
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action, tier)
|
|
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
|
|
}
|
|
|
|
// findSlotView finds one of the 5 standard equipment slots by name. It is the
|
|
// server-side source of truth for a take-off / upgrade / repair: the request
|
|
// names a slot, and every other fact (name, next tier, price, repair cost) is
|
|
// read from here rather than trusted from the client.
|
|
func findSlotView(slots []storage.EquipSlotView, slot string) (storage.EquipSlotView, bool) {
|
|
if slot == "" {
|
|
return storage.EquipSlotView{}, false
|
|
}
|
|
for _, sv := range slots {
|
|
if sv.Slot == slot {
|
|
return sv, true
|
|
}
|
|
}
|
|
return storage.EquipSlotView{}, 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})
|
|
}
|