mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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:
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user