adventure: run the web's three new verbs through the game's own paths

The game-side half of setting out, going back in, and hiring the sitter from
the web. Each is the existing command minus its framing: performExpeditionStart,
performResume and performBabysitPurchase now hold the guards and the money, and
!expedition start, !resume and !adventure babysit are what is left over. So a
departure booked from a phone is the same departure - same eligibility chain,
same supply freebies, same opening log line - rather than a second one that
drifts.

Refusals travel as advRefusal, which wraps a sentinel AND carries the finished
sentence. That is what lets the commands keep the exact copy they always sent
while the web gets a machine-readable verdict.

All three spend coins on a retrying wire, so the debit is keyed to the order
guid and a re-offer cannot charge twice. The subtle half is what a re-offer
should ANSWER: a settled debit plus an already-started expedition means the
order worked and lost its ack, not that the player is busy, so it reports
applied instead of refusing the thing it did. Nothing refunds-then-retries -
after a refund the keyed debit will not charge again, so a retry would hand over
the goods for free, and every failure past the debit is therefore permanent.

Also fixes a deadlock that predates all of this: !expedition extract and
!expedition resume are aliases for two commands that take the per-user lock
themselves, and the alias dispatcher already held it. Since it is a plain
sync.Mutex the handler blocked forever and, because the deferred unlock never
ran, every later adventure command from that player wedged too. It does not
fail loudly on regression - it hangs - so the new test asserts with a timeout.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 19:47:45 -07:00
parent fff2aa79d0
commit f73ab56ac8
9 changed files with 1050 additions and 162 deletions
+85 -10
View File
@@ -540,13 +540,13 @@ type RealmOccupant struct {
// recovered by gogobee at push time from the run history, which is why this is
// pushed rather than derived on Pete.
type RealmFirst struct {
Kind string `json:"kind"` // "zone" | "treasure"
Target string `json:"target"` // the zone id or treasure key
Display string `json:"display"` // the human name for it
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
Token string `json:"token,omitempty"` // board token; empty when opted out
AtUnix int64 `json:"at_unix"` // when the realm first saw it
Kind string `json:"kind"` // "zone" | "treasure"
Target string `json:"target"` // the zone id or treasure key
Display string `json:"display"` // the human name for it
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
Token string `json:"token,omitempty"` // board token; empty when opted out
AtUnix int64 `json:"at_unix"` // when the realm first saw it
}
// RealmStanding is one adventurer's line on the board. Every number here is a
@@ -631,6 +631,63 @@ type PlayerDetail struct {
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
// an absent one — dropping it would let the confirm dialog show a stale amount.
Balance float64 `json:"balance"`
// Zones is where this adventurer may go right now, priced. It is the offer
// list behind the web's "send on expedition" picker: level gating and the T6
// postgame gate are resolved here, so a zone the player cannot enter is simply
// absent rather than shown and then refused. Empty while they are already out.
Zones []ZoneOffer `json:"zones,omitempty"`
// Resume is the extracted expedition waiting to be walked back into, priced
// the same way. Absent when there is nothing to resume.
Resume *ResumeOffer `json:"resume,omitempty"`
// Babysit is the pet-care subscription's standing and price. Always present
// for a live adventurer: "you already have one" is as useful to the page as a
// price is.
Babysit *BabysitOffer `json:"babysit,omitempty"`
}
// ZoneOffer is one place the owner may set out for, with what the trip costs.
// Pete renders these and does no arithmetic — the prices are the game's, quoted
// at push time, and gogobee re-quotes them for real when the order lands.
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 for a zone: what it is called, what it
// costs, and roughly how long it lasts. Key is the token 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"` // provisions at the zone's daily burn
}
// ResumeOffer is the extracted expedition the owner can still walk back into,
// with the same priced loadouts as a fresh departure. ExpiresAt is the end of
// the seven-day window, so the page can say how long is left rather than just
// 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 sitter's standing and price. WeekCost/MonthCost are the
// two durations the game sells; they scale with level, which is why they are
// pushed rather than hardcoded on Pete.
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, carrying what the web
@@ -1007,15 +1064,33 @@ type AdvOrder struct {
OwnerLocalpart string `json:"owner_localpart"`
Token string `json:"token"`
CharacterName string `json:"character_name"`
Action string `json:"action"` // extract / siege_join
Action string `json:"action"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
// Params is the verb's arguments, and only the verbs that take any carry it:
// which zone, which supply loadout, how many days of sitting. It never names
// an adventurer — that still comes from the session on Pete's side — and
// every field in it is re-resolved against the game's own tables before it
// means anything, so a forged zone or a forged price buys nothing.
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.
// Anything 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, for expedition_start / resume
Days int `json:"days,omitempty"` // 7 or 30, for babysit
}
// Action names, the wire contract's half of storage.AdvAction* on Pete.
const (
AdvOrderExtract = "extract"
AdvOrderSiegeJoin = "siege_join"
AdvOrderExtract = "extract"
AdvOrderSiegeJoin = "siege_join"
AdvOrderExpedition = "expedition_start"
AdvOrderResume = "expedition_resume"
AdvOrderBabysit = "babysit"
)
// PendingOrders asks Pete for web actions waiting on us. A Pete predating the