package storage import ( "database/sql" "encoding/json" "errors" "fmt" ) // The action queue: the second channel that carries intent back to the game box, // and the first one that acts on the adventurer rather than on their kit. // // Same shape as the equip queue, deliberately — a signed-in owner asks for // something on a page they own, Pete records only the intent, gogobee polls, // runs the real rule against its own tables, and files a verdict Pete renders. // Pete never ends an expedition and never swings at a boss; it records that // somebody asked to. // // The reason this is its own table rather than more actions on equip_orders is // vocabulary: an equip order is about an item in a slot at a tier, and none of // those columns mean anything to "leave the dungeon". See the schema comment. // // Neither verb is naturally idempotent — an extract ends a run, a bout spends // the day's only swing — so gogobee guards on the order guid before it mutates, // exactly as the equip poller does. On Pete's side the mechanic is the equip // queue's: a verdict only moves a still-pending row, so a retried verdict is a // no-op. // AdvOrder is one requested action and its current standing. type AdvOrder struct { GUID string `json:"guid"` OwnerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee OwnerLocalpart string `json:"owner_localpart"` // Matrix localpart gogobee turns into an MXID — whose adventurer acts Token string `json:"token,omitempty"` // the roster token ownership was proven against; display/audit only CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it Action string `json:"action"` Status string `json:"status"` Detail string `json:"detail,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at,omitempty"` // Params is the verb's arguments, and only the three verbs that take any carry // it. It never names an adventurer — that still comes from the session — and // nothing in it is trusted: Pete resolves every field against the owner's own // pushed offer list before storing it, and gogobee resolves it again against // the game's tables before it means anything. Params *AdvOrderParams `json:"params,omitempty"` } // AdvOrderParams is the union of every verb's arguments, flat rather than // per-verb because there are three of them and each reads one or two fields. // A field a verb does not read is ignored rather than rejected. type AdvOrderParams struct { Zone string `json:"zone,omitempty"` // zone id, for expedition_start Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy Days int `json:"days,omitempty"` // 7 or 30, for babysit } // Actions. These cross the wire to gogobee, so they are part of the contract. // // extract pull out of a running expedition, keeping loot/XP, resumable for a // week — the game's `!extract`. Leader-only, which gogobee enforces. // siege_join take today's one bout against the world boss — `!adventure // worldboss fight`. The narration still lands in Matrix; the web gets // the damage line as the verdict. // W5b adds the three that take arguments and spend coins: // // expedition_start leave town for a zone with a supply loadout — `!expedition // start `. The most common action in the game // and, until now, Matrix-only. // expedition_resume walk back into the run you extracted from, re-outfitted — // `!resume`. The other half of W5a's extract: that verb's own // verdict tells people to type !resume, and this is the door. // babysit engage the pet sitter for a week or a month — `!adventure // babysit week|month`. // // W9 adds the three that undo the ones above. Each was already named inside a // refusal or a confirm this page shows — "`!expedition abandon` first", // "`!expedition leave` to walk out alone", "no refund if you cancel early" — so // until now the web told people to go and type a command it could have offered. // None takes an argument and none spends a euro: // // expedition_abandon end the expedition outright, for the whole party. Leader // only, which gogobee enforces. Also the way to close an // extracted run without paying to walk back into it first. // expedition_leave walk out of somebody else's party alone, supplies left in // the pool. Member only — the leader's row IS the expedition. // babysit_cancel dismiss the sitter early. No refund, by the game's design. const ( AdvActionExtract = "extract" AdvActionSiegeJoin = "siege_join" AdvActionExpedition = "expedition_start" AdvActionResume = "expedition_resume" AdvActionBabysit = "babysit" AdvActionAbandon = "expedition_abandon" AdvActionLeave = "expedition_leave" AdvActionBabysitCancel = "babysit_cancel" ) // Order states. Terminal states are enumerated rather than free-text so the page // can say something specific about each; detail carries gogobee's prose. The // rejection set is honest to what the game paths can actually answer. const ( AdvOrderPending = "pending" // placed; gogobee hasn't acted yet AdvOrderApplied = "applied" // it happened; detail says what AdvRejectedNotRunning = "rejected_not_running" // extract: no active expedition AdvRejectedNotLeader = "rejected_not_leader" // extract: a party member can't call the extraction AdvRejectedNoSiege = "rejected_no_siege" // siege_join: nothing camped outside town AdvRejectedAlreadyFought = "rejected_already_fought" // siege_join: today's bout is spent AdvRejectedUnavailable = "rejected_unavailable" // no character, dead, or an argument the game does not sell // W5b's three verbs. AdvRejectedBusy = "rejected_busy" // already out, already seated, or already has a sitter AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed // W9's two. rejected_is_leader is deliberately not rejected_not_leader read // backwards: they are opposite facts about the same person, and collapsing // them would answer a leader who tried to walk out by telling them they are // not the leader. AdvRejectedIsLeader = "rejected_is_leader" // expedition_leave: the leader's row is the expedition AdvRejectedNothingToCancel = "rejected_nothing_to_cancel" // babysit_cancel: no sitter is engaged ) func validAdvAction(action string) bool { switch action { case AdvActionExtract, AdvActionSiegeJoin, AdvActionExpedition, AdvActionResume, AdvActionBabysit, AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel: return true } return false } // validAdvVerdict is the set of terminal states gogobee may hand back. func validAdvVerdict(status string) bool { switch status { case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader, AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable, AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked, AdvRejectedNothingToResume, AdvRejectedIsLeader, AdvRejectedNothingToCancel: return true } return false } var ErrNoSuchAdvOrder = errors.New("orders: no such order") // InsertAdvOrder records a fresh, pending order and returns it with a new guid. // The guid is minted here so the owner has a stable reference the instant they // click, before gogobee has heard of it. The caller has already proved the signed- // in viewer owns this adventurer; whether the action is *legal right now* is // gogobee's answer, at verdict time. func InsertAdvOrder(ownerSub, ownerLocalpart, token, characterName, action string, params *AdvOrderParams) (AdvOrder, error) { if !validAdvAction(action) { return AdvOrder{}, fmt.Errorf("orders: bad action %q", action) } // Store the canonical re-serialised form, never the client's bytes: the caller // has already resolved every field against the owner's own offer list, so what // goes in the row is Pete's understanding of the request rather than the // request itself. paramsJSON := "" if params != nil { b, err := json.Marshal(params) if err != nil { return AdvOrder{}, fmt.Errorf("orders: marshal params: %w", err) } paramsJSON = string(b) } guid, err := newGUID() if err != nil { return AdvOrder{}, err } now := nowUnix() if _, err := Get().Exec( `INSERT INTO adventure_orders (guid, owner_sub, owner_localpart, token, character_name, action, status, params, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, paramsJSON, now, now, ); err != nil { return AdvOrder{}, fmt.Errorf("orders: insert order: %w", err) } return AdvOrder{ GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart, Token: token, CharacterName: characterName, Action: action, Status: AdvOrderPending, Params: params, CreatedAt: now, UpdatedAt: now, }, nil } // PendingAdvOrders is gogobee's poll: every order still waiting. Like the equip // queue there is no claimed-but-stale window — a gogobee that dies mid-apply // leaves the order pending to be offered again, and its own guid ledger makes the // replay a no-op. func PendingAdvOrders(limit int) ([]AdvOrder, error) { if limit <= 0 { limit = 100 } rows, err := Get().Query( `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at FROM adventure_orders WHERE status = ? ORDER BY created_at LIMIT ?`, AdvOrderPending, limit, ) if err != nil { return nil, fmt.Errorf("orders: pending orders: %w", err) } defer rows.Close() return scanAdvOrders(rows) } // ResolveAdvOrder files gogobee's verdict against a pending order. Idempotent by // the equip queue's mechanic: the UPDATE only moves a still-pending row, and the // row is read back unconditionally so a first verdict, a retried verdict, and a // missing row all take one path. func ResolveAdvOrder(guid, status, detail string) (AdvOrder, error) { if !validAdvVerdict(status) { return AdvOrder{}, fmt.Errorf("orders: bad verdict %q", status) } now := nowUnix() if _, err := Get().Exec( `UPDATE adventure_orders SET status = ?, detail = ?, updated_at = ? WHERE guid = ? AND status = ?`, status, detail, now, guid, AdvOrderPending, ); err != nil { return AdvOrder{}, fmt.Errorf("orders: resolve order: %w", err) } return AdvOrderByGUID(guid) } // AdvOrderByGUID reads one order. func AdvOrderByGUID(guid string) (AdvOrder, error) { rows, err := Get().Query( `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at FROM adventure_orders WHERE guid = ?`, guid, ) if err != nil { return AdvOrder{}, fmt.Errorf("orders: read order: %w", err) } defer rows.Close() out, err := scanAdvOrders(rows) if err != nil { return AdvOrder{}, err } if len(out) == 0 { return AdvOrder{}, ErrNoSuchAdvOrder } return out[0], nil } // AdvOrdersByOwner returns an owner's own recent orders, newest first, for the // status strip. Keyed on the OIDC subject so a rename doesn't strand history. func AdvOrdersByOwner(ownerSub string, limit int) ([]AdvOrder, error) { if limit <= 0 { limit = 20 } rows, err := Get().Query( `SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), COALESCE(params, ''), created_at, updated_at FROM adventure_orders WHERE owner_sub = ? ORDER BY created_at DESC LIMIT ?`, ownerSub, limit, ) if err != nil { return nil, fmt.Errorf("orders: orders by owner: %w", err) } defer rows.Close() return scanAdvOrders(rows) } // HasPendingAdvOrder reports whether this owner already has an unanswered order // of this action outstanding. Unlike the equip queue's burst counter this is a // correctness guard, not anti-spam: two queued extracts would apply in sequence // and the second would come back "no expedition to leave", which reads as a // failure for something that in fact worked. func HasPendingAdvOrder(ownerSub, action string) (bool, error) { var n int err := Get().QueryRow( `SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND action = ? AND status = ?`, ownerSub, action, AdvOrderPending, ).Scan(&n) if err != nil { return false, fmt.Errorf("orders: pending lookup: %w", err) } return n > 0, nil } // CountAdvOrdersSince backs the web anti-spam guard, same role as the equip // queue's: the real eligibility is gogobee's at verdict time, this only blunts a // stuck mouse button. func CountAdvOrdersSince(ownerSub string, since int64) (int, error) { var n int err := Get().QueryRow( `SELECT COUNT(*) FROM adventure_orders WHERE owner_sub = ? AND created_at >= ?`, ownerSub, since, ).Scan(&n) if err != nil { return 0, fmt.Errorf("orders: count recent orders: %w", err) } return n, nil } func scanAdvOrders(rows *sql.Rows) ([]AdvOrder, error) { var out []AdvOrder for rows.Next() { var o AdvOrder var params string if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.Token, &o.CharacterName, &o.Action, &o.Status, &o.Detail, ¶ms, &o.CreatedAt, &o.UpdatedAt); err != nil { return nil, fmt.Errorf("orders: scan order: %w", err) } // Unparseable params are dropped rather than failing the read. The row is // still a real order somebody placed, and a verb whose arguments went // missing is refused honestly by gogobee ("that order didn't say where // to") — which beats the whole poll erroring on one bad row. if params != "" { var pp AdvOrderParams if err := json.Unmarshal([]byte(params), &pp); err == nil { o.Params = &pp } } out = append(out, o) } return out, rows.Err() }