adventure: let a player leave town from the web, not only read about it
W5a gave the web two verbs that cost nothing. These are the three that take arguments and spend coins: set out for a zone with a supply loadout, walk back into the run you extracted from, hire the pet sitter for a week or a month. Between them they cover the most common thing anybody does in the game, which until now could only be typed into Matrix. Arguments are the new surface, so they are the thing to be careful with. Nothing in a request is trusted: every zone, loadout and duration is looked up in the offer list gogobee pushed onto that owner's own private row, and the order stores what was found there rather than what was sent. A forged zone resolves to nothing and never becomes an order. gogobee then re-resolves all of it anyway, because an offer is a quote and a quote is not a permission. The money confirm is the equip panel's, lifted: cost, balance, and the balance it leaves. When it does not cover, it says so instead of printing a negative. Verified in a browser rather than only in tests, which is where both real defects came from: the confirm box was appending to the whole panel and so appeared at the bottom of the section instead of under the button that raised it, and button prices printed as EUR45000 above a dialog reading EUR45,000. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
+94
-5
@@ -34,10 +34,19 @@ const (
|
||||
advOrderBurstMax = 30
|
||||
)
|
||||
|
||||
// advOrderReq is the browser's request. Just the verb: see the file comment on
|
||||
// why nothing identifies the character.
|
||||
// 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"`
|
||||
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
|
||||
@@ -62,7 +71,8 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
|
||||
case storage.AdvActionExtract, storage.AdvActionSiegeJoin,
|
||||
storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit:
|
||||
default:
|
||||
writeAdvOrderError(w, http.StatusBadRequest, "bad action")
|
||||
return
|
||||
@@ -133,7 +143,16 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action)
|
||||
// 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")
|
||||
@@ -144,6 +163,76 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
|
||||
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. The two W5a verbs take no arguments and resolve to nil.
|
||||
//
|
||||
// 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, ""
|
||||
}
|
||||
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}, ""
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user