package web import ( "encoding/json" "errors" "io" "log/slog" "net/http" "time" "pete/internal/storage" ) // The action queue's web seam — the first verbs the web can play, as opposed to // the equip queue's dressing-up. // // Two audiences, same shape as equip and mischief. A signed-in owner clicks // "Pull out" on their own adventurer page or "Take your bout" on the war room; // gogobee hits the bearer-authed pair, polling pending orders and pushing a // verdict. Pete runs no game rule: it records that somebody asked, and renders // what gogobee answered. The UI says "asked for" and never claims it landed. // // The character is resolved from the SESSION, never from the request. A session // maps to exactly one localpart and a localpart to exactly one adventurer, so // there is nothing for the client to name and therefore nothing to forge — the // equip queue has to take an item id and a slot off the wire and re-resolve them; // this one has no such surface at all. // advOrderBurstWindow / advOrderBurstMax blunt a stuck mouse button. The real // gates are gogobee's — one extraction ends the run, one bout per day — and the // pending-order guard below stops the common double-click outright. const ( advOrderBurstWindow = time.Hour advOrderBurstMax = 30 ) // advOrderReq is the browser's request: the verb, and for the three verbs that // take arguments, which zone / which loadout / how many days. See the file // comment on why nothing here identifies the character. // // None of these fields is trusted. Each is looked up in the owner's OWN offer // list — the one gogobee pushed onto their private self-detail row — and the // order stores what was found there, not what was sent. So a forged zone id // resolves to nothing and is refused before an order exists. type advOrderReq struct { Action string `json:"action"` Zone string `json:"zone,omitempty"` Loadout string `json:"loadout,omitempty"` Days int `json:"days,omitempty"` } // handleAdvOrder places a pending action for the signed-in owner. It asserts what // Pete can honestly know — the viewer is signed in, and gogobee has pushed a // self-detail row for them, which is gogobee's own proof that this person has an // adventurer. Everything about whether the action is legal *right now* is // gogobee's, at verdict time; the pre-checks here only produce a better message // than a verdict thirty seconds later would. func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } owner := buyerLocalpart(u) if owner == "" { writeAdvOrderError(w, http.StatusConflict, "please sign in again") return } var req advOrderReq if !decodeStateBody(w, r, &req) { return } switch req.Action { case storage.AdvActionExtract, storage.AdvActionSiegeJoin, storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit, storage.AdvActionAbandon, storage.AdvActionLeave, storage.AdvActionBabysitCancel: default: writeAdvOrderError(w, http.StatusBadRequest, "bad action") return } // Ownership. The self-detail row is gogobee's own owner<->adventurer proof, the // same join the who page's private panels and the alert sender use. No row means // this account has no adventurer — or gogobee has stopped pushing, in which case // an order it can't attribute is not one we should queue. token, ok := storage.SelfToken(owner) if !ok { writeAdvOrderError(w, http.StatusForbidden, "no adventurer on the board for this account") return } // One outstanding order per verb. Two queued extracts would apply in sequence // and the second would answer "no expedition to leave" — a rejection for // something that worked, which is the worst thing this strip could say. if pending, err := storage.HasPendingAdvOrder(u.Sub, req.Action); err != nil { slog.Error("orders: pending lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } else if pending { writeAdvOrderError(w, http.StatusConflict, "already asked — waiting on the game box") return } since := time.Now().Add(-advOrderBurstWindow).Unix() if n, err := storage.CountAdvOrdersSince(u.Sub, since); err != nil { slog.Error("orders: burst count", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } else if n >= advOrderBurstMax { writeAdvOrderError(w, http.StatusTooManyRequests, "slow down, too many requests in a short while") return } // Per-verb pre-checks, all courtesy only. Both read Pete's snapshot copy, which // is up to two minutes behind the game box, so neither is authoritative and // neither is allowed to be the last word — a run that ended in that window comes // back from gogobee as rejected_not_running, which is the honest answer. characterName := "" entry, haveEntry, err := storage.RosterEntryByToken(token) if err != nil { slog.Error("orders: roster lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if haveEntry { characterName = entry.Name } switch req.Action { case storage.AdvActionExtract: if haveEntry && entry.Status != "expedition" { writeAdvOrderError(w, http.StatusConflict, "you're not on an expedition") return } case storage.AdvActionSiegeJoin: snap, known, err := storage.LoadSiege() if err != nil { slog.Error("orders: siege lookup", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if known && !snap.Active { writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town") return } } // Resolve the verb's arguments against this owner's own offers. Everything // this returns came out of gogobee's push, so the stored order can only ever // name a zone, a loadout and a price the game itself quoted to this player. params, msg := resolveAdvOrderParams(owner, token, req) if msg != "" { writeAdvOrderError(w, http.StatusConflict, msg) return } order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action, params) if err != nil { slog.Error("orders: insert order", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } slog.Info("orders: action placed", "guid", order.GUID, "owner", owner, "action", req.Action) w.Header().Set("Cache-Control", "no-store") writeJSON(w, order) } // resolveAdvOrderParams turns the browser's arguments into the stored ones by // looking each up in the owner's pushed offer list, and returns the reason to // refuse when it cannot. Five of the eight verbs take no arguments and resolve to // nil — but babysit_cancel still comes through here, because the offer row is // the one place Pete can see that there is no sitter to dismiss. // // Note what W5a's "Pete has never heard about it" asymmetry does NOT need to // become here. There is no such state to defer on: the detail row this reads is // the same row SelfToken already found, so by the time we get here it exists. // What a gogobee too old to push offers produces is an EMPTY offer list, and // then the page renders no picker at all — so there is no dead button to protect // against, only forged arguments to refuse. func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOrderParams, string) { switch req.Action { case storage.AdvActionExtract, storage.AdvActionSiegeJoin: return nil, "" case storage.AdvActionAbandon, storage.AdvActionLeave: // No snapshot pre-check for either, deliberately, and it is the same call // W5a made for extract: the board is up to two minutes stale, so the only // thing Pete could test — "the mark reads idle" — would refuse actions the // game would have allowed. Abandon is worse than extract in that respect, // because an *extracted* expedition is still abandonable while its owner // reads as standing in town. gogobee answers rejected_not_running / // rejected_not_leader / rejected_is_leader, and the strip shows it. return nil, "" } detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token) if err != nil { slog.Error("orders: detail lookup", "err", err) return nil, "couldn't read your adventurer just now" } if !haveDetail { // Only reachable if the row went away between SelfToken and here — the // roster push replaces the whole table. Refuse rather than guess. return nil, "couldn't read your adventurer just now" } switch req.Action { case storage.AdvActionExpedition: if req.Zone == "" { return nil, "pick somewhere to go first" } if len(detail.Zones) == 0 { return nil, "you're already out there" } for _, z := range detail.Zones { if z.ID != req.Zone { continue } for _, l := range z.Loadouts { if l.Key == req.Loadout { return &storage.AdvOrderParams{Zone: z.ID, Loadout: l.Key}, "" } } return nil, "that isn't a loadout for that zone" } return nil, "that zone isn't open to you" case storage.AdvActionResume: if detail.Resume == nil { return nil, "there's no expedition waiting for you" } for _, l := range detail.Resume.Loadouts { if l.Key == req.Loadout { return &storage.AdvOrderParams{Loadout: l.Key}, "" } } return nil, "that isn't a loadout for that zone" case storage.AdvActionBabysit: if req.Days != 7 && req.Days != 30 { return nil, "the sitter works by the week or by the month" } if detail.Babysit != nil && detail.Babysit.Active { return nil, "a sitter is already looking after your camp" } return &storage.AdvOrderParams{Days: req.Days}, "" case storage.AdvActionBabysitCancel: // The mirror of the check above, and the one W9 verb where the snapshot // really does contradict the request: a sitter's engagement is a fact about // the character, not about where they are standing, so it does not go stale // the way "on an expedition" does. A missing offer is still not a refusal — // that is a gogobee too old to push one, not a player without a sitter. if detail.Babysit != nil && !detail.Babysit.Active { return nil, "there's no sitter to dismiss" } return nil, "" } return nil, "bad action" } // handleAdvOrders returns the signed-in owner's own recent actions for the status // strip, newest first. Scoped to their OIDC subject. func (s *Server) handleAdvOrders(w http.ResponseWriter, r *http.Request) { u := s.requireUser(w, r) if u == nil { return } orders, err := storage.AdvOrdersByOwner(u.Sub, 10) if err != nil { slog.Error("orders: by owner", "err", err) writeAdvOrderError(w, http.StatusInternalServerError, "internal error") return } if orders == nil { orders = []storage.AdvOrder{} } w.Header().Set("Cache-Control", "no-store") writeJSON(w, orders) } // ---- the gogobee wire: bearer-authed, idempotent ------------------------------- // advOrderPollLimit caps one poll, matching the equip and mischief seams. const advOrderPollLimit = 50 // handleAdvOrdersPending is gogobee's poll: every action still waiting. Like the // seams beside it there is no stale-reoffer window — a gogobee that dies mid-apply // leaves the order pending to be offered again, and its guid ledger makes the // replay a no-op. func (s *Server) handleAdvOrdersPending(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } orders, err := storage.PendingAdvOrders(advOrderPollLimit) if err != nil { slog.Error("orders: pending", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if orders == nil { orders = []storage.AdvOrder{} } writeJSON(w, orders) } // advOrderVerdict is gogobee's answer on an order: the terminal status and a // human note to render. type advOrderVerdict struct { GUID string `json:"guid"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } // handleAdvOrderVerdict 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 // this seam's contract that parks the row for a human rather than retrying // forever against a row that will never exist. func (s *Server) handleAdvOrderVerdict(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var v advOrderVerdict 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.ResolveAdvOrder(v.GUID, v.Status, v.Detail) if errors.Is(err, storage.ErrNoSuchAdvOrder) { slog.Error("orders: 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 errors.Is(err, storage.ErrBadAdvVerdict) { slog.Error("orders: verdict outside the terminal set", "guid", v.GUID, "status", v.Status) http.Error(w, "bad verdict", http.StatusBadRequest) return } if err != nil { // A storage failure, not a bad request. 400 here would park a perfectly // resolvable order forever on a transient database error; 500 gets it // retried on gogobee's next poll. slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } slog.Info("orders: action resolved", "guid", order.GUID, "action", order.Action, "status", order.Status) writeJSON(w, order) } func writeAdvOrderError(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}) }