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:
+77
-16
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
@@ -32,11 +33,26 @@ type AdvOrder struct {
|
||||
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"` // extract / siege_join
|
||||
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.
|
||||
@@ -46,14 +62,27 @@ type AdvOrder struct {
|
||||
// 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 <zone> <loadout>`. 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`.
|
||||
const (
|
||||
AdvActionExtract = "extract"
|
||||
AdvActionSiegeJoin = "siege_join"
|
||||
AdvActionExtract = "extract"
|
||||
AdvActionSiegeJoin = "siege_join"
|
||||
AdvActionExpedition = "expedition_start"
|
||||
AdvActionResume = "expedition_resume"
|
||||
AdvActionBabysit = "babysit"
|
||||
)
|
||||
|
||||
// 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 two game paths can actually answer.
|
||||
// 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
|
||||
@@ -62,12 +91,19 @@ const (
|
||||
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, or dead
|
||||
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
|
||||
)
|
||||
|
||||
func validAdvAction(action string) bool {
|
||||
switch action {
|
||||
case AdvActionExtract, AdvActionSiegeJoin:
|
||||
case AdvActionExtract, AdvActionSiegeJoin,
|
||||
AdvActionExpedition, AdvActionResume, AdvActionBabysit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -77,7 +113,9 @@ func validAdvAction(action string) bool {
|
||||
func validAdvVerdict(status string) bool {
|
||||
switch status {
|
||||
case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader,
|
||||
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable:
|
||||
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable,
|
||||
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
|
||||
AdvRejectedNothingToResume:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -90,10 +128,22 @@ var ErrNoSuchAdvOrder = errors.New("orders: no such order")
|
||||
// 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) (AdvOrder, error) {
|
||||
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
|
||||
@@ -101,16 +151,16 @@ func InsertAdvOrder(ownerSub, ownerLocalpart, token, characterName, action strin
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO adventure_orders
|
||||
(guid, owner_sub, owner_localpart, token, character_name, action, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, ownerSub, ownerLocalpart, token, characterName, action, AdvOrderPending, now, now,
|
||||
(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, CreatedAt: now, UpdatedAt: now,
|
||||
Status: AdvOrderPending, Params: params, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +173,7 @@ func PendingAdvOrders(limit int) ([]AdvOrder, error) {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), created_at, updated_at
|
||||
`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
|
||||
@@ -159,7 +209,7 @@ func ResolveAdvOrder(guid, status, detail string) (AdvOrder, error) {
|
||||
// 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, ''), created_at, updated_at
|
||||
`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 {
|
||||
@@ -183,7 +233,7 @@ func AdvOrdersByOwner(ownerSub string, limit int) ([]AdvOrder, error) {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, token, character_name, action, status, COALESCE(detail, ''), created_at, updated_at
|
||||
`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
|
||||
@@ -233,11 +283,22 @@ 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,
|
||||
&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()
|
||||
|
||||
Reference in New Issue
Block a user