Files
gogobee/internal/plugin/pete_offers.go
prosolis f73ab56ac8 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
2026-07-24 19:47:45 -07:00

130 lines
4.6 KiB
Go

package plugin
// The offer half of the web action queue: what a signed-in owner is allowed to
// ask for, and what it costs.
//
// W5a's two verbs needed none of this — "pull out" and "take your bout" have no
// arguments and no price. W5b's three do, and the page cannot invent either: the
// zone list is level-gated and postgame-gated per player, and every price scales
// with level. So gogobee quotes them here, on the self-detail push that already
// carries the owner's private panels, and Pete renders the quote without doing
// any arithmetic of its own.
//
// A quote is NOT a permission. It is up to two minutes stale by the time anybody
// clicks it, so every one of these is re-resolved against the game's own tables
// when the order lands (performExpeditionStart re-runs availableZonesFor,
// performBabysitPurchase re-reads the level). What the offer list buys is a page
// that does not show a button which is certain to be refused.
import (
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// advLoadoutKeys is the order the three presets are offered in — cheapest first,
// which is also how renderLoadoutPrompt lists them in Matrix.
var advLoadoutKeys = []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy}
// loadoutOffersFor prices the three supply presets at a tier. Days is the
// provisions estimate at that tier's calm daily burn — the same number
// `!expedition start` prints, and deliberately the pessimistic one: the holiday
// and Omen freebie packs are added at departure, so a run can outlast its quote
// but never fall short of it.
func loadoutOffersFor(tier ZoneTier) []peteclient.LoadoutOffer {
out := make([]peteclient.LoadoutOffer, 0, len(advLoadoutKeys))
for _, l := range advLoadoutKeys {
pp := loadoutPurchase(tier, l)
sup := makeSupplies(tier, pp)
out = append(out, peteclient.LoadoutOffer{
Key: loadoutName(l),
Name: loadoutName(l),
Blurb: loadoutBlurb(l),
Cost: pp.Cost(),
Days: estimateDays(sup.Max, sup.DailyBurn),
})
}
return out
}
// zoneOffersFor is where this adventurer may set out for right now, priced.
//
// It returns nothing at all when they cannot leave — already on an expedition,
// seated in somebody else's, or mid zone-run. That is not a second permission
// check duplicating performExpeditionStart's; it is what stops the page offering
// a departure it can already tell will be refused.
func zoneOffersFor(uid id.UserID) []peteclient.ZoneOffer {
if seated, _ := seatedExpeditionFor(uid); seated != nil {
return nil
}
if existing, _ := getActiveExpedition(uid); existing != nil {
return nil
}
if run, _ := getActiveZoneRun(uid); run != nil {
return nil
}
zones := availableZonesFor(uid, dndLevelForUser(uid))
out := make([]peteclient.ZoneOffer, 0, len(zones))
for _, z := range zones {
out = append(out, peteclient.ZoneOffer{
ID: string(z.ID),
Display: z.Display,
Tier: int(z.Tier),
Hook: z.Hook,
Postgame: z.Tier == ZoneTierMythic,
Loadouts: loadoutOffersFor(z.Tier),
})
}
return out
}
// resumeOfferFor is the extracted expedition still waiting to be walked back
// into. Nil when there is none, when the window has already lapsed, or when the
// player is out again — a lapsed row is left for the sweeper to reap rather than
// reaped here, because a push builder should not be quietly ending expeditions.
func resumeOfferFor(uid id.UserID) *peteclient.ResumeOffer {
if existing, _ := getActiveExpedition(uid); existing != nil {
return nil
}
exp, err := getResumableExpedition(uid)
if err != nil || exp == nil {
return nil
}
if extractionLapsed(exp, time.Now().UTC()) {
return nil
}
zone, _ := getZone(exp.ZoneID)
off := &peteclient.ResumeOffer{
ZoneID: string(exp.ZoneID),
Display: zone.Display,
Tier: int(zone.Tier),
Day: exp.CurrentDay,
Loadouts: loadoutOffersFor(zone.Tier),
}
if exp.CompletedAt != nil {
off.ExpiresAt = exp.CompletedAt.Add(extractResumeWindow).Unix()
}
return off
}
// babysitOfferFor is the sitter's standing and the two prices they charge. It is
// pushed even when a sitter is already engaged: "somebody is already looking
// after your pet until Tuesday" is exactly what the page should say instead of a
// buy button.
func babysitOfferFor(adv *AdventureCharacter) *peteclient.BabysitOffer {
if adv == nil {
return nil
}
daily := babysitDailyCost(dndLevelForUser(adv.UserID))
off := &peteclient.BabysitOffer{
Active: adv.BabysitActive,
WeekCost: daily * 7,
MonthCost: daily * 30,
}
if adv.BabysitActive && adv.BabysitExpiresAt != nil {
off.ExpiresAt = adv.BabysitExpiresAt.Unix()
}
return off
}