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:
prosolis
2026-07-24 19:47:26 -07:00
parent 6b0aae9f4a
commit 868a29e992
11 changed files with 685 additions and 57 deletions
+5
View File
@@ -117,6 +117,11 @@ func runMigrations(d *sql.DB) error {
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
// W5b: the three verbs that take arguments (which zone, which loadout, how
// many days of sitting) carry them as one small JSON object. W5a's two verbs
// take none, so an existing row gets '' and reads back as no params — which is
// exactly what extract and siege_join mean.
addColumnIfMissing(d, "adventure_orders", "params", "TEXT NOT NULL DEFAULT ''")
// Adventure alerts. A subscription made before they existed knows only the OIDC
// subject, and the adventure ownership join needs the Matrix localpart — so an
// existing row gets "" here and is skipped for owner-scoped alerts until the
+53
View File
@@ -25,6 +25,59 @@ type PlayerDetail struct {
Slots []EquipSlotView `json:"slots,omitempty"`
// Balance is the owner's euro balance, for the upgrade/repair confirm dialogs.
Balance float64 `json:"balance,omitempty"`
// Zones / Resume / Babysit are the W5b action offers: what this owner may ask
// for from the web right now, priced by gogobee. Pete renders these and does no
// arithmetic — every price and every gate is the game's, quoted at push time.
//
// An offer is NOT a permission. It is up to two minutes stale, so gogobee
// re-resolves the zone, the price and the fee when the order lands. What the
// list buys is a page that does not offer a button certain to be refused.
Zones []ZoneOffer `json:"zones,omitempty"`
Resume *ResumeOffer `json:"resume,omitempty"`
Babysit *BabysitOffer `json:"babysit,omitempty"`
}
// ZoneOffer is one place the owner may set out for. Absent entirely while they
// are already out, so an empty list means "not right now" rather than "nowhere".
type ZoneOffer struct {
ID string `json:"id"`
Display string `json:"display"`
Tier int `json:"tier"`
Hook string `json:"hook,omitempty"`
Postgame bool `json:"postgame,omitempty"`
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
}
// LoadoutOffer is one supply preset: what it is called, what it costs, and how
// many days of provisions it buys. Key is what the order carries back.
type LoadoutOffer struct {
Key string `json:"key"` // lean|balanced|heavy
Name string `json:"name"`
Blurb string `json:"blurb,omitempty"`
Cost int `json:"cost"`
Days int `json:"days"`
}
// ResumeOffer is the extracted expedition still waiting to be walked back into.
// ExpiresAt is the end of the seven-day window, so the page can say how long is
// left rather than only that there is a way back.
type ResumeOffer struct {
ZoneID string `json:"zone_id"`
Display string `json:"display"`
Tier int `json:"tier"`
Day int `json:"day"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Loadouts []LoadoutOffer `json:"loadouts,omitempty"`
}
// BabysitOffer is the pet sitter's standing and the two prices they charge. It
// is pushed even when a sitter is engaged: "looked after until Tuesday" is what
// the page should say instead of a buy button.
type BabysitOffer struct {
Active bool `json:"active"`
ExpiresAt int64 `json:"expires_at,omitempty"`
WeekCost int `json:"week_cost"`
MonthCost int `json:"month_cost"`
}
// EquipSlotView is one of the 5 standard equipment slots as gogobee pushed it,
+77 -16
View File
@@ -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, &params,
&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()
+7 -7
View File
@@ -8,7 +8,7 @@ import (
func TestAdvOrderRoundTrip(t *testing.T) {
setupTestDB(t)
o, err := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionExtract)
o, err := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionExtract, nil)
if err != nil {
t.Fatalf("insert: %v", err)
}
@@ -40,7 +40,7 @@ func TestAdvOrderRoundTrip(t *testing.T) {
// retries its verdict push, so the second one must be a read, not a write.
func TestAdvOrderVerdictOnlyMovesAPendingRow(t *testing.T) {
setupTestDB(t)
o, _ := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionSiegeJoin)
o, _ := InsertAdvOrder("sub-1", "josie", "tok-josie", "Josie", AdvActionSiegeJoin, nil)
if _, err := ResolveAdvOrder(o.GUID, AdvOrderApplied, "first"); err != nil {
t.Fatalf("first verdict: %v", err)
}
@@ -56,10 +56,10 @@ func TestAdvOrderVerdictOnlyMovesAPendingRow(t *testing.T) {
func TestAdvOrderRejectsBadInput(t *testing.T) {
setupTestDB(t)
if _, err := InsertAdvOrder("sub-1", "josie", "tok", "Josie", "sell_house"); err == nil {
if _, err := InsertAdvOrder("sub-1", "josie", "tok", "Josie", "sell_house", nil); err == nil {
t.Fatal("an unknown action was accepted")
}
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract)
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
if _, err := ResolveAdvOrder(o.GUID, "exploded", ""); err == nil {
t.Fatal("an unknown verdict was accepted")
}
@@ -72,7 +72,7 @@ func TestAdvOrderRejectsBadInput(t *testing.T) {
// not the other button.
func TestHasPendingAdvOrderIsPerVerb(t *testing.T) {
setupTestDB(t)
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract)
o, _ := InsertAdvOrder("sub-1", "josie", "tok", "Josie", AdvActionExtract, nil)
if got, _ := HasPendingAdvOrder("sub-1", AdvActionExtract); !got {
t.Fatal("a pending extract wasn't seen")
@@ -94,10 +94,10 @@ func TestHasPendingAdvOrderIsPerVerb(t *testing.T) {
func TestAdvOrdersByOwnerScopes(t *testing.T) {
setupTestDB(t)
if _, err := InsertAdvOrder("sub-A", "alice", "tok-a", "Alice", AdvActionExtract); err != nil {
if _, err := InsertAdvOrder("sub-A", "alice", "tok-a", "Alice", AdvActionExtract, nil); err != nil {
t.Fatalf("insert: %v", err)
}
if _, err := InsertAdvOrder("sub-B", "bob", "tok-b", "Bob", AdvActionExtract); err != nil {
if _, err := InsertAdvOrder("sub-B", "bob", "tok-b", "Bob", AdvActionExtract, nil); err != nil {
t.Fatalf("insert: %v", err)
}
got, err := AdvOrdersByOwner("sub-A", 10)
+2 -1
View File
@@ -421,9 +421,10 @@ CREATE TABLE IF NOT EXISTS adventure_orders (
owner_localpart TEXT NOT NULL,
token TEXT NOT NULL DEFAULT '',
character_name TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL, -- extract / siege_join
action TEXT NOT NULL, -- see the AdvAction* set
status TEXT NOT NULL, -- see the ladder above
detail TEXT, -- gogobee's human note on the verdict
params TEXT NOT NULL DEFAULT '', -- the verb's arguments as JSON; '' for the verbs that take none
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);