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,
+75 -14
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"
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
);
+38
View File
@@ -713,6 +713,44 @@ var funcs = template.FuncMap{
}
return m
},
// euro formats a whole-euro price with thousands separators. The money confirm
// in the browser already does this via toLocaleString, and a button reading
// "€45000" above a dialog reading "€45,000" looks like two different prices.
"euro": func(n int) string {
s := strconv.Itoa(n)
neg := strings.HasPrefix(s, "-")
if neg {
s = s[1:]
}
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
if neg {
s = "-" + s
}
return s
},
// untilUnix is timeAgo's mirror: how long is LEFT, for a deadline the reader
// can still act on. Rounded down deliberately — a window with 47 hours in it
// says "1 day left", which is the safe way to be wrong about a deadline.
"untilUnix": func(unix int64) string {
if unix <= 0 {
return ""
}
d := time.Until(time.Unix(unix, 0))
switch {
case d <= 0:
return "closed"
case d < time.Hour:
return "less than an hour left"
case d < 24*time.Hour:
return fmt.Sprintf("%dh left", int(d.Hours()))
case d < 48*time.Hour:
return "1 day left"
default:
return fmt.Sprintf("%d days left", int(d.Hours())/24)
}
},
"timeAgo": func(t time.Time) string {
d := time.Since(t)
switch {
+93 -4
View File
@@ -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"`
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) {
+177 -1
View File
@@ -150,7 +150,7 @@ func TestActionOrdersAreScopedToTheirOwner(t *testing.T) {
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
t.Fatalf("place = %d", w.Code)
}
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract); err != nil {
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract, nil); err != nil {
t.Fatalf("insert other: %v", err)
}
@@ -245,3 +245,179 @@ func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *http
s.handleAdvOrderVerdict(w, req)
return w
}
// ── W5b: the three verbs that take arguments ─────────────────────────────────
// seedOffers is seedActions with an offer list on the private detail row — which
// is what gogobee pushes, and what every W5b param is resolved against.
func seedOffers(t *testing.T, owner, status string, pd storage.PlayerDetail) *Server {
t.Helper()
s, _ := newAdvServer(t, "tok")
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
now := time.Now().Unix()
e := entry("tok-josie", "Josie", status, owner)
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
t.Fatalf("seed roster = %d", w.Code)
}
pd.Localpart = owner
pd.Token = "tok-josie"
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{pd}}); w.Code != 200 {
t.Fatalf("seed detail = %d", w.Code)
}
return s
}
func offeredZones() []storage.ZoneOffer {
return []storage.ZoneOffer{{
ID: "goblin_warrens", Display: "Goblin Warrens", Tier: 1,
Loadouts: []storage.LoadoutOffer{
{Key: "lean", Name: "lean", Cost: 40, Days: 3},
{Key: "balanced", Name: "balanced", Cost: 80, Days: 5},
},
}}
}
func placeParams(t *testing.T, s *Server, username string, req advOrderReq) *httptest.ResponseRecorder {
t.Helper()
r := as(t, s, username, "POST", "/api/adventure/order", req)
w := httptest.NewRecorder()
s.handleAdvOrder(w, r)
return w
}
// The whole point of resolving params against the owner's own offer list: a
// forged zone, or a loadout that zone does not sell, must never reach an order
// row. gogobee would refuse them anyway — this is the cheap answer, thirty
// seconds earlier, and it keeps the queue clean.
func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "dragons_lair", Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("forged zone = %d, want 409 (%s)", w.Code, w.Body.String())
}
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "enormous",
}); w.Code != 409 {
t.Fatalf("forged loadout = %d, want 409 (%s)", w.Code, w.Body.String())
}
w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "balanced",
})
if w.Code != 200 {
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got storage.AdvOrder
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Params == nil || got.Params.Zone != "goblin_warrens" || got.Params.Loadout != "balanced" {
t.Fatalf("params = %+v, want the resolved zone and loadout", got.Params)
}
// And they survive the round trip to gogobee's poll, which is the only reason
// they are stored at all.
pending, err := storage.PendingAdvOrders(10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Params == nil || pending[0].Params.Zone != "goblin_warrens" {
t.Fatalf("pending params lost in the round trip: %+v", pending)
}
}
// An empty zone list means "already out there", not "nowhere to go" — gogobee
// omits the offers entirely while the adventurer is on an expedition. Refusing
// cheaply here beats a verdict thirty seconds later saying the same thing.
func TestNoZoneOffersMeansAlreadyOut(t *testing.T) {
s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{})
w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
})
if w.Code != 409 {
t.Fatalf("departure with no offers = %d, want 409 (%s)", w.Code, w.Body.String())
}
}
// The sitter sells two durations and nothing else, and is not sold twice.
func TestBabysitParamsAreTheTwoDurationsOnly(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Babysit: &storage.BabysitOffer{WeekCost: 700, MonthCost: 3000},
})
for _, days := range []int{0, 3, 365} {
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: days,
}); w.Code != 409 {
t.Fatalf("%d-day sitter = %d, want 409", days, w.Code)
}
}
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: 30,
}); w.Code != 200 {
t.Fatalf("month = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Already engaged: the page should not be offering this at all, but a stale
// tab can still post it.
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 3000},
})
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionBabysit, Days: 7,
}); w.Code != 409 {
t.Fatalf("second sitter = %d, want 409", w.Code)
}
}
// Resume is refused when the snapshot positively says there is nothing waiting,
// and accepted with a loadout the offer actually lists.
func TestResumeParamsNeedAnOfferedLoadout(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("resume with nothing waiting = %d, want 409", w.Code)
}
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
Resume: &storage.ResumeOffer{ZoneID: "goblin_warrens", Display: "Goblin Warrens", Day: 3,
Loadouts: []storage.LoadoutOffer{{Key: "lean", Name: "lean", Cost: 40, Days: 3}}},
})
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "heavy",
}); w.Code != 409 {
t.Fatalf("unoffered loadout = %d, want 409", w.Code)
}
if w := placeParams(t, s2, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 200 {
t.Fatalf("offered loadout = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// The offer list is the whole gate, so it is worth pinning that an empty one is
// a refusal rather than a pass-through: gogobee omits the zones while the
// adventurer is out, and a pass-through there would queue a departure that is
// certain to come back "you're already on expedition".
//
// There is deliberately no "Pete has never heard of this player" case to test:
// the detail row this resolves against is the same row the ownership check
// already found, so it always exists by then. A gogobee too old to push offers
// yields an empty list and the page renders no picker at all.
func TestParamsResolveOnlyAgainstAPushedOffer(t *testing.T) {
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
}); w.Code != 200 {
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Resume is not on offer for this player at all, so it is refused even though
// the loadout key is a real one from the zone list above.
if w := placeParams(t, s, "holymachina", advOrderReq{
Action: storage.AdvActionResume, Loadout: "lean",
}); w.Code != 409 {
t.Fatalf("resume with no offer = %d, want 409", w.Code)
}
}
File diff suppressed because one or more lines are too long
+120 -25
View File
@@ -5,8 +5,13 @@
// box acts on its next poll. So nothing here ever says "done" on its own. It says
// "asked for", then shows whatever verdict gogobee filed, including a refusal.
//
// Shared by the adventurer page (pull out of a run) and the war room (take
// today's bout), which is why it lives in a file rather than inline in either.
// Shared by the adventurer page (pull out, go back in, set out, hire the sitter)
// and the war room (take today's bout), which is why it lives in a file rather
// than inline in either.
//
// Three of the verbs spend euros, so those confirm the cost and the resulting
// balance first — the equip panel's rule, and for the same reason: a button that
// quietly moves money is a button people stop pressing.
(function () {
var panels = Array.prototype.slice.call(document.querySelectorAll('.adv-actions'));
if (!panels.length) return; // not an owner, or not a page with actions
@@ -24,7 +29,11 @@
rejected_not_leader: "couldn't, only the party leader can call it",
rejected_no_siege: "couldn't, no Siege is camped outside town",
rejected_already_fought: "couldn't, today's bout is already spent",
rejected_unavailable: "couldn't right now"
rejected_unavailable: "couldn't right now",
rejected_busy: "couldn't, you're already out there",
rejected_insufficient_funds: "couldn't cover it",
rejected_zone_locked: "couldn't, that zone isn't open to you",
rejected_nothing_to_resume: "couldn't, there's nothing waiting for you"
};
// syncOffers keeps the panel's own copy from outliving the truth. Watching it
@@ -49,17 +58,58 @@
offer.classList.add('hidden');
return;
}
// Both halves of the restore matter, and the second is easy to forget:
// re-enabling a button inside a wrapper this function hid on an earlier
// pass gives back a control nobody can see.
// All three halves of the restore matter, and each is easy to forget.
// Un-hiding the wrapper: re-enabling a button inside a wrapper an earlier
// pass hid gives back a control nobody can see. Restoring the LABEL: the
// clicked button still says "asked for". And doing it to every button in
// the offer, not just the one whose action matched — placeOrder disables
// the whole group (three loadouts, or the sitter's two durations), so
// restoring one would leave the rest greyed out for good.
offer.classList.remove('hidden');
btn.disabled = false;
btn.classList.remove('opacity-50');
btn.textContent = btn.getAttribute('data-label') || btn.textContent;
var group = offer.querySelectorAll('.adv-action-btn[data-action="' + action + '"]');
Array.prototype.forEach.call(group, function (b) {
b.disabled = false;
b.classList.remove('opacity-50');
b.textContent = b.getAttribute('data-label') || b.textContent;
});
});
}
var VERB = { extract: 'Pull out', siege_join: 'Join the defence' };
var VERB = {
extract: 'Pull out',
siege_join: 'Join the defence',
expedition_start: 'Set out',
expedition_resume: 'Go back in',
babysit: 'Hire the sitter'
};
// The owner's euro balance as of the render, for the money confirms. Absent on
// the war room, whose one verb is free — euroFmt(NaN) never runs there because
// no button on that page carries a data-cost.
var panelBalance = (function () {
var el = document.querySelector('.adv-actions[data-balance]');
return el ? parseFloat(el.getAttribute('data-balance') || '0') : 0;
})();
function euroFmt(n) {
return (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
}
// The zone picker shows one loadout row at a time: prices are per tier, so each
// zone gets its own server-rendered row and this only swaps which is visible.
// Pete never reprices anything in the browser.
(function initZonePicker() {
var pick = document.getElementById('adv-zone-pick');
if (!pick) return;
var groups = Array.prototype.slice.call(document.querySelectorAll('.adv-zone-loadouts'));
function show() {
groups.forEach(function (g) {
g.classList.toggle('hidden', g.getAttribute('data-zone') !== pick.value);
});
}
pick.addEventListener('change', show);
show();
})();
var pollTimer = null;
@@ -105,21 +155,38 @@
.catch(function () { /* transient — a later tick will do */ });
}
// siblings are every button in the same offer — the three loadouts of a zone,
// the sitter's week and month. All of them are disabled together, because the
// game allows one outstanding order per verb and a second click would be
// refused with "already asked", which reads as a broken button rather than as
// the guard it is.
function siblings(btn) {
var offer = btn.closest('[data-offer]');
if (!offer) return [btn];
return Array.prototype.slice.call(offer.querySelectorAll('.adv-action-btn'));
}
function placeOrder(btn) {
btn.disabled = true;
btn.classList.add('opacity-50');
var group = siblings(btn);
group.forEach(function (b) { b.disabled = true; b.classList.add('opacity-50'); });
var was = btn.textContent;
btn.textContent = 'asking…';
var body = { action: btn.getAttribute('data-action') };
// Only the verbs that take arguments send any. An attribute that is not on
// the button is simply absent from the body, which is what extract and
// siege_join mean.
if (btn.hasAttribute('data-zone')) body.zone = btn.getAttribute('data-zone');
if (btn.hasAttribute('data-loadout')) body.loadout = btn.getAttribute('data-loadout');
if (btn.hasAttribute('data-days')) body.days = parseInt(btn.getAttribute('data-days'), 10);
fetch('/api/adventure/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: btn.getAttribute('data-action') })
body: JSON.stringify(body)
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
.then(function (res) {
if (!res.ok) {
btn.disabled = false;
btn.classList.remove('opacity-50');
group.forEach(function (b) { b.disabled = false; b.classList.remove('opacity-50'); });
btn.textContent = (res.body && res.body.error) || 'try again';
setTimeout(function () { btn.textContent = was; }, 4000);
return;
@@ -128,20 +195,26 @@
loadOrders();
})
.catch(function () {
btn.disabled = false;
btn.classList.remove('opacity-50');
group.forEach(function (b) { b.disabled = false; b.classList.remove('opacity-50'); });
btn.textContent = 'try again';
setTimeout(function () { btn.textContent = was; }, 4000);
});
}
// Both verbs are one-way an extraction ends the run for the whole party and a
// bout is the only one you get today — so both confirm. Built in the DOM rather
// than with confirm(), which would block the event loop and, on this site's own
// evidence, wedge an automated browser.
// Every verb confirms. The two free ones are one-way (an extraction ends the
// run for the whole party; a bout is the only one you get today) and the three
// W5b ones spend euros, so those also print the cost and the balance it leaves
// — the equip panel's money gate, lifted.
//
// Built in the DOM rather than with confirm(), which would block the event loop
// and, on this site's own evidence, wedge an automated browser.
function askConfirm(btn) {
var panel = btn.closest('.adv-actions') || btn.parentElement;
var existing = panel.querySelector('.adv-action-confirm');
// Anchor the confirm to the OFFER, not to the panel. With one offer on the
// page those were the same element; with four they are not, and appending to
// the panel put the "set out?" box at the bottom of the section, under the
// sitter, detached from the button that raised it.
var panel = btn.closest('[data-offer]') || btn.closest('.adv-actions') || btn.parentElement;
var existing = document.querySelector('.adv-action-confirm');
if (existing) existing.remove();
var boxEl = document.createElement('div');
@@ -149,12 +222,33 @@
var p = document.createElement('p');
p.className = 'text-[color:var(--ink)]/70';
p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?';
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
if (cost > 0) {
var money = document.createElement('p');
money.className = 'mt-1.5 font-semibold text-[color:var(--ink)]/80';
if (panelBalance - cost < 0) {
// No arrow when it does not cover. The game can carry a small debt, so
// the resulting figure is not always nonsense — but printing "€-6,300"
// next to "that won't cover it" is one number too many, and the minus
// lands on the wrong side of the sign.
money.textContent = '€' + euroFmt(cost) + " — you have €" + euroFmt(panelBalance) +
". That won't cover it.";
money.className += ' text-[color:var(--warn)]';
} else {
money.textContent = '€' + euroFmt(cost) + ' — balance €' + euroFmt(panelBalance) +
' → €' + euroFmt(panelBalance - cost) + '.';
}
boxEl.appendChild(p);
boxEl.appendChild(money);
p = null; // already placed; the tail below appends whatever is left
}
var row = document.createElement('div');
row.className = 'mt-2 flex gap-1.5';
var yes = document.createElement('button');
yes.type = 'button';
yes.className = 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold';
yes.textContent = btn.getAttribute('data-confirm-label') || 'Yes, do it';
yes.textContent = (btn.getAttribute('data-confirm-label') || 'Yes, do it') +
(cost > 0 ? ' · €' + euroFmt(cost) : '');
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
var no = document.createElement('button');
no.type = 'button';
@@ -162,7 +256,8 @@
no.textContent = 'Not yet';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes); row.appendChild(no);
boxEl.appendChild(p); boxEl.appendChild(row);
if (p) boxEl.appendChild(p);
boxEl.appendChild(row);
panel.appendChild(boxEl);
}
+111 -1
View File
@@ -203,7 +203,8 @@
would be the page refusing an action the game would have allowed. gogobee
answers rejected_not_running if it really has ended, and that answer shows
up in the strip below. -->
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete"
data-balance="{{.Self.Balance}}">
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
Asked for here, done on the game box. It picks these up within a few seconds.
@@ -221,6 +222,115 @@
data-confirm="Pull out of the dungeon now? You keep the loot, XP and coins you're carrying, and the run waits where you left it: you have seven days to go back in. If you're leading a party, it ends the day for all of you.">Pull out of the run</button>
</div>
{{/* W5b. Everything below is server-rendered from the offer list gogobee
pushed onto this owner's own private row, so the page can only ever
propose a zone, a loadout and a price the game itself quoted. The order
carries those keys back and gogobee re-resolves all of them — an offer is
a quote, never a permission. */}}
{{if .Self.Resume}}
{{/* The way back in, first: it is the only offer here with a deadline on it,
and it is the answer to the verdict "Pull out" leaves behind, which until
now told a web player to go and type !resume in Matrix. */}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-base font-bold">Go back in</h3>
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
{{.Self.Resume.Display}}, day {{.Self.Resume.Day}} — waiting where you left it.
{{with untilUnix .Self.Resume.ExpiresAt}}<span class="text-[color:var(--warn)] font-semibold">{{.}}</span>.{{end}}
You re-stock before you go, so pick a pack.
</p>
<div class="flex flex-wrap gap-1.5">
{{$rz := .Self.Resume}}
{{range $rz.Loadouts}}
<button type="button"
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_resume"
data-loadout="{{.Key}}"
data-cost="{{.Cost}}"
data-label="{{.Name}} · €{{euro .Cost}}"
data-confirm-label="Go back in"
data-confirm="Walk back into {{$rz.Display}} on day {{$rz.Day}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
{{end}}
</div>
</div>
{{end}}
{{if .Self.Zones}}
{{/* Set out. The zone list is already level-gated and postgame-gated by
gogobee, and it arrives EMPTY while this adventurer is already out —
which is why there is no "you're on an expedition" branch here. */}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-base font-bold">Set out</h3>
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
Pick where, then pick how much to carry. The pack is what you pay for.
</p>
<label class="block">
<span class="sr-only">Zone</span>
<select id="adv-zone-pick"
class="w-full rounded-xl bg-[color:var(--ink)]/5 border border-[color:var(--ink)]/15 px-3 py-2 text-sm font-semibold">
{{range .Self.Zones}}
<option value="{{.ID}}">{{.Display}} · T{{.Tier}}{{if .Postgame}} · mythic{{end}}</option>
{{end}}
</select>
</label>
{{/* One group per zone, all rendered, one shown. The costs differ by tier,
so a single shared row of buttons would have to be repriced in the
browser — and Pete does no arithmetic on the game's money. */}}
{{range .Self.Zones}}
{{$z := .}}
<div class="adv-zone-loadouts mt-2.5 hidden" data-zone="{{$z.ID}}">
{{with $z.Hook}}<p class="text-xs italic text-[color:var(--ink)]/50 mb-2">{{.}}</p>{{end}}
<div class="flex flex-wrap gap-1.5">
{{range $z.Loadouts}}
<button type="button"
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_start"
data-zone="{{$z.ID}}"
data-loadout="{{.Key}}"
data-cost="{{.Cost}}"
data-label="{{.Name}} · €{{euro .Cost}}"
data-confirm-label="Set out"
data-confirm="Head for {{$z.Display}} with a {{.Name}} pack: {{.Blurb}}. About {{.Days}} days of provisions.">{{.Name}} · €{{euro .Cost}}</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
{{if .Self.Babysit}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-base font-bold">The sitter</h3>
{{if .Self.Babysit.Active}}
{{/* Engaged: say so and offer nothing. Buying a second one is refused by
the game anyway, and a buy button under "already engaged" reads as a
page that has not noticed. */}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">
Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}.
Your pet is being tended daily and standard camps rest like fortified ones.
</p>
{{else}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
Pet tended daily, standard camps rest like fortified ones, rival duels declined for you.
</p>
<div class="flex flex-wrap gap-1.5">
<button type="button"
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="babysit" data-days="7" data-cost="{{.Self.Babysit.WeekCost}}"
data-label="A week · €{{euro .Self.Babysit.WeekCost}}"
data-confirm-label="Hire for a week"
data-confirm="Engage the sitter for seven days. No refund if you cancel early.">A week · €{{euro .Self.Babysit.WeekCost}}</button>
<button type="button"
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="babysit" data-days="30" data-cost="{{.Self.Babysit.MonthCost}}"
data-label="A month · €{{euro .Self.Babysit.MonthCost}}"
data-confirm-label="Hire for a month"
data-confirm="Engage the sitter for thirty days. No refund if you cancel early.">A month · €{{euro .Self.Babysit.MonthCost}}</button>
</div>
{{end}}
</div>
{{end}}
<!-- The queue is honest: an action lands on the game box's next poll, so a
fresh one reads "asked for", never "done". JS fills this from
/api/adventure/orders. -->