mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41: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:
@@ -631,6 +631,63 @@ type PlayerDetail struct {
|
|||||||
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
|
// 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.
|
// an absent one — dropping it would let the confirm dialog show a stale amount.
|
||||||
Balance float64 `json:"balance"`
|
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
|
// 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"`
|
OwnerLocalpart string `json:"owner_localpart"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
CharacterName string `json:"character_name"`
|
CharacterName string `json:"character_name"`
|
||||||
Action string `json:"action"` // extract / siege_join
|
Action string `json:"action"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
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.
|
// Action names, the wire contract's half of storage.AdvAction* on Pete.
|
||||||
const (
|
const (
|
||||||
AdvOrderExtract = "extract"
|
AdvOrderExtract = "extract"
|
||||||
AdvOrderSiegeJoin = "siege_join"
|
AdvOrderSiegeJoin = "siege_join"
|
||||||
|
AdvOrderExpedition = "expedition_start"
|
||||||
|
AdvOrderResume = "expedition_resume"
|
||||||
|
AdvOrderBabysit = "babysit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PendingOrders asks Pete for web actions waiting on us. A Pete predating the
|
// PendingOrders asks Pete for web actions waiting on us. A Pete predating the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -100,33 +101,84 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
// Sentinels for the ways hiring a sitter can be refused, so the web action queue
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// (pete_orders.go) can pick a verdict without parsing prose. Each is returned
|
||||||
|
// inside an advRefusal carrying the finished sentence, so `!adventure babysit`
|
||||||
|
// keeps the copy it always sent.
|
||||||
|
var (
|
||||||
|
errBabysitNoCharacter = errors.New("babysit: no adventurer")
|
||||||
|
errBabysitActive = errors.New("babysit: a sitter is already engaged")
|
||||||
|
errBabysitDead = errors.New("babysit: adventurer is dead")
|
||||||
|
errBabysitBroke = errors.New("babysit: cannot cover the fee")
|
||||||
|
errBabysitFailed = errors.New("babysit: could not engage a sitter")
|
||||||
|
)
|
||||||
|
|
||||||
|
// babysitOutcome is what hiring did, for a caller describing it somewhere other
|
||||||
|
// than a DM.
|
||||||
|
type babysitOutcome struct {
|
||||||
|
Days int
|
||||||
|
Cost int
|
||||||
|
PetName string
|
||||||
|
PetLine string
|
||||||
|
Confirm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performBabysitPurchase is `!adventure babysit week|month` minus the command
|
||||||
|
// framing. Shared with the web action queue so hiring a sitter from a phone
|
||||||
|
// engages the same one, on the same clock, with the same log reset.
|
||||||
|
//
|
||||||
|
// idemKey, when set, is the web order's guid and moves the fee onto DebitIdem so
|
||||||
|
// a re-offered order cannot charge twice.
|
||||||
|
func (p *AdventurePlugin) performBabysitPurchase(uid id.UserID, days int, idemKey string) (babysitOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
char, err := loadAdvCharacter(ctx.Sender)
|
char, err := loadAdvCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
return babysitOutcome{}, refuseAdv(errBabysitNoCharacter, "No adventurer found. Type `!adventure` to create one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if char.BabysitActive {
|
if char.BabysitActive {
|
||||||
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
// A web order that already paid and already engaged the sitter lands here
|
||||||
|
// on the re-offer. The settled fee is what tells that apart from somebody
|
||||||
|
// who really does already have one.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||||
|
return babysitOutcome{Days: days, PetName: char.PetName}, nil
|
||||||
|
}
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitActive, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
|
return babysitOutcome{}, refuseAdv(errBabysitDead, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||||
}
|
}
|
||||||
|
|
||||||
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
||||||
totalCost := daily * days
|
totalCost := daily * days
|
||||||
|
if p.euro == nil {
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
// Skip the affordability gate on a re-offer that already paid: the fee is a
|
||||||
|
// settled fact, and re-reading the now-lower balance would bounce a sitter the
|
||||||
|
// player has bought.
|
||||||
|
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||||
balance := p.euro.GetBalance(char.UserID)
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
if balance < float64(totalCost) {
|
if balance < float64(totalCost) {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
|
return babysitOutcome{}, refuseAdv(errBabysitBroke,
|
||||||
|
"🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.",
|
||||||
|
fmtEuro(totalCost), days, fmtEuro(balance))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
|
debited := false
|
||||||
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
|
if idemKey != "" {
|
||||||
|
ok, _, err := p.euro.DebitIdem(char.UserID, float64(totalCost), "babysit_purchase", idemKey)
|
||||||
|
debited = err == nil && ok
|
||||||
|
} else {
|
||||||
|
debited = p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase")
|
||||||
|
}
|
||||||
|
if !debited {
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBabysitLogs(char.UserID)
|
clearBabysitLogs(char.UserID)
|
||||||
@@ -138,23 +190,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
|||||||
|
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||||
|
if idemKey != "" {
|
||||||
|
if _, _, err := p.euro.CreditIdem(char.UserID, float64(totalCost), "babysit_refund", idemKey+":refund"); err != nil {
|
||||||
|
slog.Error("babysit: refund failed", "user", char.UserID, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
}
|
||||||
|
return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.")
|
||||||
}
|
}
|
||||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||||
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
|
||||||
durLabel := "1 week"
|
|
||||||
if days == 30 {
|
|
||||||
durLabel = "1 month"
|
|
||||||
}
|
|
||||||
|
|
||||||
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
||||||
if char.HasPet() {
|
if char.HasPet() {
|
||||||
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
||||||
}
|
}
|
||||||
|
return babysitOutcome{
|
||||||
|
Days: days, Cost: totalCost, PetName: char.PetName, PetLine: petLine,
|
||||||
|
Confirm: pickBabysitFlavor(babysitConfirmLines),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBabysitPurchase is the command framing around performBabysitPurchase.
|
||||||
|
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||||
|
out, err := p.performBabysitPurchase(ctx.Sender, days, "")
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
durLabel := "1 week"
|
||||||
|
if days == 30 {
|
||||||
|
durLabel = "1 month"
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||||
"Duration: %s (%d days)\n"+
|
"Duration: %s (%d days)\n"+
|
||||||
@@ -162,7 +231,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
|||||||
"%s\n"+
|
"%s\n"+
|
||||||
"Camp safety: standard camps now rest like fortified ones\n"+
|
"Camp safety: standard camps now rest like fortified ones\n"+
|
||||||
"Rival duels: declined on your behalf\n\n"+
|
"Rival duels: declined on your behalf\n\n"+
|
||||||
"_%s_", durLabel, days, totalCost, petLine, confirm)
|
"_%s_", durLabel, days, out.Cost, out.PetLine, out.Confirm)
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@@ -32,6 +33,23 @@ import (
|
|||||||
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
sub, rest := splitFirstWord(args)
|
||||||
|
|
||||||
|
// Two subcommands are aliases for top-level commands that take the per-user
|
||||||
|
// lock themselves. Dispatch them BEFORE we take it: advUserLock is a plain
|
||||||
|
// sync.Mutex, so grabbing it here and again in there does not merely block —
|
||||||
|
// it wedges the lock forever, because the deferred Unlock below never runs.
|
||||||
|
// Every later `!adventure` / `!expedition` / `!zone` command from that player
|
||||||
|
// then hangs too. Both aliases load their own character, so nothing below is
|
||||||
|
// being skipped.
|
||||||
|
switch strings.ToLower(sub) {
|
||||||
|
case "extract":
|
||||||
|
return p.handleExtractCmd(ctx, "")
|
||||||
|
case "resume":
|
||||||
|
return p.handleResumeCmd(ctx, rest)
|
||||||
|
}
|
||||||
|
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
userMu := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
@@ -45,8 +63,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
||||||
}
|
}
|
||||||
|
|
||||||
args = strings.TrimSpace(args)
|
|
||||||
sub, rest := splitFirstWord(args)
|
|
||||||
switch strings.ToLower(sub) {
|
switch strings.ToLower(sub) {
|
||||||
case "":
|
case "":
|
||||||
// If active, show status; otherwise help. A party member is on an
|
// If active, show status; otherwise help. A party member is on an
|
||||||
@@ -100,10 +116,6 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
return p.expeditionCmdHire(ctx, rest)
|
return p.expeditionCmdHire(ctx, rest)
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
return p.expeditionCmdDismiss(ctx)
|
return p.expeditionCmdDismiss(ctx)
|
||||||
case "extract":
|
|
||||||
return p.handleExtractCmd(ctx, "")
|
|
||||||
case "resume":
|
|
||||||
return p.handleResumeCmd(ctx, rest)
|
|
||||||
case "map", "m":
|
case "map", "m":
|
||||||
return p.handleExpeditionMapCmd(ctx, "")
|
return p.handleExpeditionMapCmd(ctx, "")
|
||||||
case "run", "explore", "advance":
|
case "run", "explore", "advance":
|
||||||
@@ -332,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||||||
}
|
}
|
||||||
if err := purchase.Validate(zoneForCaps.Tier); err != nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
||||||
}
|
|
||||||
// Reject if any expedition or zone run already active. This runs before the
|
|
||||||
// price quote: a player who cannot leave doesn't need to hear what leaving
|
|
||||||
// would have cost.
|
|
||||||
//
|
|
||||||
// The seat check spans `extracting` as well as `active` — a member of an
|
|
||||||
// extracting party is still seated for the seven-day resume window, and
|
|
||||||
// letting them outfit a rival expedition double-books them the moment their
|
|
||||||
// leader types `!resume`.
|
|
||||||
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
|
|
||||||
zone, _ := getZone(seated.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
|
||||||
zone.Display, seated.CurrentDay))
|
|
||||||
}
|
|
||||||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
|
||||||
zone, _ := getZone(existing.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
|
||||||
zone.Display, existing.CurrentDay))
|
|
||||||
}
|
|
||||||
// A leader who extracted still holds their roster for the resume window, and
|
|
||||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
|
||||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
|
||||||
// catches it, with every member still seated and refused a run of their own.
|
|
||||||
//
|
|
||||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
|
||||||
// walking away from it stays a normal thing to do.
|
|
||||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
|
||||||
switch {
|
|
||||||
case extractionLapsed(pending, time.Now().UTC()):
|
|
||||||
// Past the window — reap it here rather than make them wait an hour
|
|
||||||
// for the sweeper, and let the new expedition proceed. Route through
|
|
||||||
// the shared reap so the freed members hear about it, same as the
|
|
||||||
// sweeper and `!expedition abandon` do.
|
|
||||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
|
||||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// A roster still holds; block. On a roster-read error, assume it is
|
|
||||||
// occupied and refuse — proceeding would orphan a party we could not
|
|
||||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
|
||||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
|
||||||
n, err := partySize(pending.ID)
|
|
||||||
if err != nil || n > 1 {
|
|
||||||
zone, _ := getZone(pending.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
|
||||||
zone.Display, pending.CurrentDay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cost := float64(purchase.Cost())
|
out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "")
|
||||||
if p.euro == nil {
|
|
||||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
||||||
}
|
|
||||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
|
||||||
int(cost), balance))
|
|
||||||
}
|
|
||||||
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
|
||||||
zone, _ := getZone(existing.ZoneID)
|
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
||||||
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
|
||||||
zone.Display))
|
|
||||||
}
|
|
||||||
|
|
||||||
zone := zoneForCaps
|
|
||||||
_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Every refusal below carries its own finished sentence — see the sentinel
|
||||||
|
// block on performExpeditionStart — so the command only has to say it.
|
||||||
return p.SendDM(ctx.Sender, err.Error())
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
}
|
}
|
||||||
markActedToday(ctx.Sender)
|
zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||||||
@@ -429,6 +372,182 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the headless twin of `!expedition start` ────────────────────────────────
|
||||||
|
|
||||||
|
// Sentinels for the ways outfitting can be refused, so the web action queue
|
||||||
|
// (pete_orders.go) can pick a verdict without parsing prose. Every refusal is
|
||||||
|
// returned as an advRefusal, which wraps one of these AND carries the
|
||||||
|
// finished player-facing sentence — that is how `!expedition start` keeps the
|
||||||
|
// exact copy it always sent while the web gets a machine-readable answer.
|
||||||
|
var (
|
||||||
|
errExpStartResting = errors.New("expedition start: still resting")
|
||||||
|
errExpStartZoneLocked = errors.New("expedition start: zone not available at this level")
|
||||||
|
errExpStartBadPacks = errors.New("expedition start: invalid pack selection")
|
||||||
|
errExpStartBusy = errors.New("expedition start: already adventuring")
|
||||||
|
errExpStartBroke = errors.New("expedition start: cannot cover outfitting")
|
||||||
|
errExpStartFailed = errors.New("expedition start: could not outfit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// advRefusal is a refusal that is both classifiable and quotable: errors.Is
|
||||||
|
// picks the verdict, Error() is the sentence the command has always sent. Shared
|
||||||
|
// by every headless twin in the web action family (start, resume, babysit).
|
||||||
|
type advRefusal struct {
|
||||||
|
kind error
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e advRefusal) Error() string { return e.msg }
|
||||||
|
func (e advRefusal) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
func refuseAdv(kind error, format string, args ...any) error {
|
||||||
|
return advRefusal{kind: kind, msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expStartOutcome is what outfitting did, for a caller describing it somewhere
|
||||||
|
// other than a DM.
|
||||||
|
type expStartOutcome struct {
|
||||||
|
Zone ZoneDefinition
|
||||||
|
Supplies ExpeditionSupplies
|
||||||
|
Cost int
|
||||||
|
Days int
|
||||||
|
StartLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performExpeditionStart is `!expedition start` minus the command framing: the
|
||||||
|
// eligibility guards, the price gate, the debit, and the expedition row. It is
|
||||||
|
// shared with the web action queue so that leaving town from a phone is the
|
||||||
|
// *same* departure — same guards, same supplies, same opening log line.
|
||||||
|
//
|
||||||
|
// idemKey, when set, is the web order's guid: the debit then goes through
|
||||||
|
// DebitIdem so a re-offered order that already paid cannot pay twice. The Matrix
|
||||||
|
// command passes "" and keeps the plain debit, which is right — a Matrix message
|
||||||
|
// arrives exactly once.
|
||||||
|
//
|
||||||
|
// LOCKING, and this is the one asymmetry in the headless-twin family: this
|
||||||
|
// function does NOT take the per-user lock. performExtraction, takeSiegeBout and
|
||||||
|
// performBabysitPurchase all take it themselves, because their command framings
|
||||||
|
// do not hold it — but handleDnDExpeditionCmd holds it across its whole switch,
|
||||||
|
// so taking it here would wedge advUserLock permanently (it is a plain
|
||||||
|
// sync.Mutex, and the deferred unlock up there would never run). The web caller
|
||||||
|
// takes it explicitly instead; see applyAdvOrder.
|
||||||
|
func (p *AdventurePlugin) performExpeditionStart(uid id.UserID, c *DnDCharacter, zone ZoneDefinition, purchase SupplyPurchase, idemKey string) (expStartOutcome, error) {
|
||||||
|
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartResting,
|
||||||
|
"🛌 You're still resting — %s remaining. Pack up after.",
|
||||||
|
formatRespecDuration(remaining))
|
||||||
|
}
|
||||||
|
// Re-resolve availability against the game's own tables rather than trusting
|
||||||
|
// the caller. The web resolves a zone from an offer list gogobee itself
|
||||||
|
// pushed, but that snapshot can be minutes old and is not a permission.
|
||||||
|
if _, ok := resolveZoneInput(string(zone.ID), availableZonesFor(uid, c.Level)); !ok {
|
||||||
|
if reason := postgameLockReason(string(zone.ID), uid, c.Level); reason != "" {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked, "%s", reason)
|
||||||
|
}
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartZoneLocked,
|
||||||
|
"Unknown zone for your level. Try `!expedition list`.")
|
||||||
|
}
|
||||||
|
if err := purchase.Validate(zone.Tier); err != nil {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBadPacks,
|
||||||
|
"Invalid pack selection: %s", err.Error())
|
||||||
|
}
|
||||||
|
// Reject if any expedition or zone run already active. This runs before the
|
||||||
|
// price quote: a player who cannot leave doesn't need to hear what leaving
|
||||||
|
// would have cost.
|
||||||
|
//
|
||||||
|
// The seat check spans `extracting` as well as `active` — a member of an
|
||||||
|
// extracting party is still seated for the seven-day resume window, and
|
||||||
|
// letting them outfit a rival expedition double-books them the moment their
|
||||||
|
// leader types `!resume`.
|
||||||
|
if seated, _ := seatedExpeditionFor(uid); seated != nil {
|
||||||
|
z, _ := getZone(seated.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
|
||||||
|
z.Display, seated.CurrentDay)
|
||||||
|
}
|
||||||
|
if existing, _ := getActiveExpedition(uid); existing != nil {
|
||||||
|
// A web order that already paid and already started this expedition on an
|
||||||
|
// earlier tick lands here on the re-offer. Saying "you're already on
|
||||||
|
// expedition" would be a rejection for the thing the order in fact did, so
|
||||||
|
// the settled debit is what tells the two apart.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) && existing.ZoneID == zone.ID {
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{Zone: z, Supplies: existing.Supplies,
|
||||||
|
Cost: purchase.Cost(),
|
||||||
|
Days: estimateDays(existing.Supplies.Max, existing.Supplies.DailyBurn)}, nil
|
||||||
|
}
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
|
z.Display, existing.CurrentDay)
|
||||||
|
}
|
||||||
|
// A leader who extracted still holds their roster for the resume window, and
|
||||||
|
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||||
|
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||||
|
// catches it, with every member still seated and refused a run of their own.
|
||||||
|
//
|
||||||
|
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||||
|
// walking away from it stays a normal thing to do.
|
||||||
|
if pending, _ := getResumableExpedition(uid); pending != nil {
|
||||||
|
switch {
|
||||||
|
case extractionLapsed(pending, time.Now().UTC()):
|
||||||
|
// Past the window — reap it here rather than make them wait an hour
|
||||||
|
// for the sweeper, and let the new expedition proceed. Route through
|
||||||
|
// the shared reap so the freed members hear about it, same as the
|
||||||
|
// sweeper and `!expedition abandon` do.
|
||||||
|
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||||
|
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// A roster still holds; block. On a roster-read error, assume it is
|
||||||
|
// occupied and refuse — proceeding would orphan a party we could not
|
||||||
|
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||||
|
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||||
|
n, err := partySize(pending.ID)
|
||||||
|
if err != nil || n > 1 {
|
||||||
|
z, _ := getZone(pending.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||||
|
z.Display, pending.CurrentDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := float64(purchase.Cost())
|
||||||
|
if p.euro == nil {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartFailed, "Coin system unavailable — try again later.")
|
||||||
|
}
|
||||||
|
// Skip the affordability gate on a re-offer that already paid: the debit is a
|
||||||
|
// settled fact and re-reading the now-lower balance would bounce a departure
|
||||||
|
// the player has bought. Same reasoning as purchaseEquipmentTier's.
|
||||||
|
if !(idemKey != "" && p.euro.HasExternalTx(idemKey)) {
|
||||||
|
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBroke,
|
||||||
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||||
|
int(cost), balance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing, _ := getActiveZoneRun(uid); existing != nil {
|
||||||
|
z, _ := getZone(existing.ZoneID)
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartBusy,
|
||||||
|
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
||||||
|
z.Display)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, supplies, startLine, err := p.beginExpeditionIdem(uid, c.Level, zone, purchase, "expedition outfitting", idemKey)
|
||||||
|
if err != nil {
|
||||||
|
// beginExpedition refunds and tears down on every failure path, so the
|
||||||
|
// player owes nothing and this is permanent rather than retryable — a retry
|
||||||
|
// after a refund would find the guid-keyed debit already settled and hand
|
||||||
|
// them the expedition for free.
|
||||||
|
return expStartOutcome{}, refuseAdv(errExpStartFailed, "%s", err.Error())
|
||||||
|
}
|
||||||
|
markActedToday(uid)
|
||||||
|
return expStartOutcome{
|
||||||
|
Zone: zone, Supplies: supplies, Cost: purchase.Cost(),
|
||||||
|
Days: estimateDays(supplies.Max, supplies.DailyBurn), StartLine: startLine,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// beginExpedition performs the non-interactive half of starting an expedition:
|
// beginExpedition performs the non-interactive half of starting an expedition:
|
||||||
// supply freebies, the coin debit, persistence, the starting region's run, and
|
// supply freebies, the coin debit, persistence, the starting region's run, and
|
||||||
// the opening log entry. It refunds and tears down on every failure path, so a
|
// the opening log entry. It refunds and tears down on every failure path, so a
|
||||||
@@ -442,10 +561,38 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
|||||||
// It deliberately does NOT call markActedToday — an expedition the player did
|
// It deliberately does NOT call markActedToday — an expedition the player did
|
||||||
// not ask for must not spend their daily action or count as them showing up.
|
// not ask for must not spend their daily action or count as them showing up.
|
||||||
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||||
|
return p.beginExpeditionIdem(uid, charLevel, zone, purchase, reason, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginExpeditionIdem is beginExpedition with the money keyed to an idempotency
|
||||||
|
// id. idemKey empty keeps the plain Debit/Credit pair, which is correct for the
|
||||||
|
// two callers that arrive exactly once (a Matrix command, the boredom ticker).
|
||||||
|
//
|
||||||
|
// A non-empty key comes from the web action queue, whose wire retries: the debit
|
||||||
|
// then lands at most once however many times the order is re-offered, and the
|
||||||
|
// refunds are keyed too so a torn-down start cannot refund on every tick. Note
|
||||||
|
// what this means for the caller — once a refund has happened, retrying is
|
||||||
|
// *unsafe*, because the guid-keyed debit will not charge again and the player
|
||||||
|
// would get the expedition for free. performExpeditionStart therefore treats
|
||||||
|
// every error from here as permanent.
|
||||||
|
func (p *AdventurePlugin) beginExpeditionIdem(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason, idemKey string) (*Expedition, ExpeditionSupplies, string, error) {
|
||||||
if p.euro == nil {
|
if p.euro == nil {
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
|
||||||
}
|
}
|
||||||
cost := float64(purchase.Cost())
|
cost := float64(purchase.Cost())
|
||||||
|
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||||
|
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||||
|
if idemKey != "" {
|
||||||
|
debit = func(why string) bool {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
refund = func(why, suffix string) {
|
||||||
|
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||||
|
slog.Error("expedition: outfitting refund failed", "user", uid, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||||
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
// snapshot without inflating the coin cost. Bypasses the per-tier cap
|
||||||
@@ -460,14 +607,14 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
|||||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||||
|
|
||||||
// Debit coins; bail on debit failure (race / cap).
|
// Debit coins; bail on debit failure (race / cap).
|
||||||
if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
|
if !debit(reason + ": " + string(zone.ID)) {
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
exp, err := startExpedition(uid, zone.ID, "", supplies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Refund on persistence failure.
|
// Refund on persistence failure.
|
||||||
p.euro.Credit(uid, cost, "expedition outfitting refund")
|
refund("expedition outfitting refund", "")
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +625,7 @@ func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone Zon
|
|||||||
// Refund and tear the expedition row back down — without a
|
// Refund and tear the expedition row back down — without a
|
||||||
// linked run, harvest and rooms can't function.
|
// linked run, harvest and rooms can't function.
|
||||||
_ = abandonExpedition(uid)
|
_ = abandonExpedition(uid)
|
||||||
p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
|
refund("expedition outfitting refund (run-spawn failed)", ":region")
|
||||||
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -447,76 +447,136 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
|
|||||||
|
|
||||||
// ── !resume command ─────────────────────────────────────────────────────────
|
// ── !resume command ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
// Sentinels for the ways going back in can be refused. Same contract as
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
// performExpeditionStart's: each one comes back inside an advRefusal that also
|
||||||
|
// carries the finished sentence, so `!resume` keeps its copy verbatim.
|
||||||
|
//
|
||||||
|
// errResumeNeedLoadout is the odd one out and deliberately so: it is not a
|
||||||
|
// refusal at all but the loadout prompt, returned as one so the whole decision
|
||||||
|
// stays inside the lock. The web never triggers it — it always names a loadout.
|
||||||
|
var (
|
||||||
|
errResumeNeedLoadout = errors.New("resume: no loadout named")
|
||||||
|
errResumeBusy = errors.New("resume: already on an expedition")
|
||||||
|
errResumeNothing = errors.New("resume: no extracted expedition")
|
||||||
|
errResumeLapsed = errors.New("resume: past the 7-day window")
|
||||||
|
errResumeBadPacks = errors.New("resume: invalid pack selection")
|
||||||
|
errResumeBroke = errors.New("resume: cannot cover outfitting")
|
||||||
|
errResumeFailed = errors.New("resume: could not resume")
|
||||||
|
)
|
||||||
|
|
||||||
|
// resumeOutcome is what going back in did, for a caller describing it somewhere
|
||||||
|
// other than a DM.
|
||||||
|
type resumeOutcome struct {
|
||||||
|
Zone ZoneDefinition
|
||||||
|
Day int
|
||||||
|
Supplies ExpeditionSupplies
|
||||||
|
Purchase SupplyPurchase
|
||||||
|
Threat int
|
||||||
|
Stack int
|
||||||
|
Line string
|
||||||
|
}
|
||||||
|
|
||||||
|
// performResume is `!resume` minus the command framing: the leader check, the
|
||||||
|
// lapse check, the re-outfitting purchase and the fresh region run. Shared with
|
||||||
|
// the web action queue so that walking back in from a phone is the same walk.
|
||||||
|
//
|
||||||
|
// loadoutTok is the raw `Ns Md` / preset token; empty asks for the prompt.
|
||||||
|
// idemKey, when set, is the web order's guid and moves the money onto the
|
||||||
|
// idempotent variants — see beginExpeditionIdem for why a refund then makes a
|
||||||
|
// retry unsafe, which is why every error here is permanent for the web caller.
|
||||||
|
func (p *AdventurePlugin) performResume(uid id.UserID, loadoutTok, idemKey string) (resumeOutcome, error) {
|
||||||
|
userMu := p.advUserLock(uid)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
c, err := LoadDnDCharacter(ctx.Sender)
|
c, err := LoadDnDCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
return resumeOutcome{}, fmt.Errorf("Couldn't load your character: %s", err)
|
||||||
}
|
}
|
||||||
if c == nil || c.PendingSetup {
|
if c == nil || c.PendingSetup {
|
||||||
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
|
return resumeOutcome{}, refuseAdv(errResumeNothing, "No Adv 2.0 character yet — run `!setup` first.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
|
if existing, isLeader, _ := activeExpeditionFor(uid); existing != nil {
|
||||||
zone, _ := getZone(existing.ZoneID)
|
zone, _ := getZone(existing.ZoneID)
|
||||||
if !isLeader {
|
if !isLeader {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||||
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
|
||||||
zone.Display, existing.CurrentDay))
|
zone.Display, existing.CurrentDay)
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
// A web order that already paid and already resumed lands here on the
|
||||||
|
// re-offer; the settled debit is what tells that apart from a player who
|
||||||
|
// really is already out. Same tell as performExpeditionStart's.
|
||||||
|
if idemKey != "" && p.euro != nil && p.euro.HasExternalTx(idemKey) {
|
||||||
|
return resumeOutcome{Zone: zone, Day: existing.CurrentDay,
|
||||||
|
Supplies: existing.Supplies, Threat: existing.ThreatLevel}, nil
|
||||||
|
}
|
||||||
|
return resumeOutcome{}, refuseAdv(errResumeBusy,
|
||||||
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||||
zone.Display, existing.CurrentDay))
|
zone.Display, existing.CurrentDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
exp, err := getResumableExpedition(ctx.Sender)
|
exp, err := getResumableExpedition(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return resumeOutcome{}, fmt.Errorf("Couldn't read expedition state: %s", err)
|
||||||
}
|
}
|
||||||
if exp == nil {
|
if exp == nil {
|
||||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
return resumeOutcome{}, refuseAdv(errResumeNothing,
|
||||||
|
"No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
||||||
}
|
}
|
||||||
if extractionLapsed(exp, time.Now().UTC()) {
|
if extractionLapsed(exp, time.Now().UTC()) {
|
||||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||||
// here on its own; this keeps the refusal and the reap in one breath.
|
// here on its own; this keeps the refusal and the reap in one breath.
|
||||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||||
return p.SendDM(ctx.Sender,
|
return resumeOutcome{}, refuseAdv(errResumeLapsed,
|
||||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeZone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(exp.ZoneID)
|
||||||
// D5-b: prompt for a preset loadout on empty args.
|
// D5-b: prompt for a preset loadout on empty args.
|
||||||
if strings.TrimSpace(args) == "" {
|
if strings.TrimSpace(loadoutTok) == "" {
|
||||||
return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume"))
|
return resumeOutcome{}, refuseAdv(errResumeNeedLoadout, "%s", renderLoadoutPrompt(zone, "resume"))
|
||||||
}
|
}
|
||||||
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(args), resumeZone.Tier)
|
purchase, err := resolveLoadoutOrParse(strings.TrimSpace(loadoutTok), zone.Tier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Couldn't parse supply packs: %s", err.Error())
|
||||||
}
|
}
|
||||||
if err := purchase.Validate(resumeZone.Tier); err != nil {
|
if err := purchase.Validate(zone.Tier); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error())
|
||||||
}
|
}
|
||||||
cost := float64(purchase.Cost())
|
cost := float64(purchase.Cost())
|
||||||
if p.euro == nil {
|
if p.euro == nil {
|
||||||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Coin system unavailable — try again later.")
|
||||||
}
|
}
|
||||||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
paid := idemKey != "" && p.euro.HasExternalTx(idemKey)
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
if !paid {
|
||||||
|
if balance := p.euro.GetBalance(uid); balance < cost {
|
||||||
|
return resumeOutcome{}, refuseAdv(errResumeBroke,
|
||||||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||||||
int(cost), balance))
|
int(cost), balance)
|
||||||
}
|
}
|
||||||
if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) {
|
}
|
||||||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
|
||||||
|
refund := func(why, suffix string) { p.euro.Credit(uid, cost, why) }
|
||||||
|
if idemKey != "" {
|
||||||
|
debit = func(why string) bool {
|
||||||
|
ok, _, err := p.euro.DebitIdem(uid, cost, why, idemKey)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
refund = func(why, suffix string) {
|
||||||
|
if _, _, err := p.euro.CreditIdem(uid, cost, why, idemKey+":refund"+suffix); err != nil {
|
||||||
|
slog.Error("expedition: resume refund failed", "user", uid, "order", idemKey, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !debit("expedition resume outfitting: " + string(exp.ZoneID)) {
|
||||||
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't debit outfitting cost (try again).")
|
||||||
}
|
}
|
||||||
|
|
||||||
zone, _ := getZone(exp.ZoneID)
|
|
||||||
supplies := makeSupplies(zone.Tier, purchase)
|
supplies := makeSupplies(zone.Tier, purchase)
|
||||||
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
if err := resumeExpedition(exp.ID, supplies); err != nil {
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund")
|
refund("expedition resume refund", "")
|
||||||
return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error())
|
||||||
}
|
}
|
||||||
exp.Status = ExpeditionStatusActive
|
exp.Status = ExpeditionStatusActive
|
||||||
exp.Supplies = supplies
|
exp.Supplies = supplies
|
||||||
@@ -527,25 +587,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
|||||||
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
exp.RegionState[regionStateRegionRuns] = map[string]string{}
|
||||||
_ = persistRegionState(exp)
|
_ = persistRegionState(exp)
|
||||||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
||||||
p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)")
|
refund("expedition resume refund (run-spawn failed)", ":region")
|
||||||
return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error())
|
return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error())
|
||||||
}
|
}
|
||||||
line := flavor.Pick(flavor.ExpeditionResume)
|
line := flavor.Pick(flavor.ExpeditionResume)
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||||
"expedition resumed", line)
|
"expedition resumed", line)
|
||||||
|
|
||||||
|
return resumeOutcome{
|
||||||
|
Zone: zone, Day: exp.CurrentDay, Supplies: supplies, Purchase: purchase,
|
||||||
|
Threat: exp.ThreatLevel, Stack: exp.TemporalStack, Line: line,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResumeCmd is `!resume`: the command framing around performResume.
|
||||||
|
func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error {
|
||||||
|
out, err := p.performResume(ctx.Sender, args, "")
|
||||||
|
if err != nil {
|
||||||
|
// Every refusal — and the loadout prompt, which travels as one — arrives
|
||||||
|
// as a finished sentence, so this only has to say it.
|
||||||
|
return p.SendDM(ctx.Sender, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n",
|
||||||
zone.Display, exp.CurrentDay))
|
out.Zone.Display, out.Day))
|
||||||
if line != "" {
|
if out.Line != "" {
|
||||||
b.WriteString(line)
|
b.WriteString(out.Line)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
||||||
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost()))
|
||||||
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel))
|
b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat))
|
||||||
if exp.TemporalStack != 0 {
|
if out.Stack != 0 {
|
||||||
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack))
|
b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack))
|
||||||
}
|
}
|
||||||
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
b.WriteString("\nUse `!expedition status` for the daily briefing.")
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
|
|||||||
@@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) {
|
|||||||
time.Since(*got.CompletedAt), extractResumeWindow)
|
time.Since(*got.CompletedAt), extractResumeWindow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `!expedition extract` and `!expedition resume` are aliases for two top-level
|
||||||
|
// commands that take the per-user lock themselves. If the alias dispatcher takes
|
||||||
|
// that lock first the handler blocks on it forever and, because the deferred
|
||||||
|
// unlock never runs, every later adventure command from that player wedges too.
|
||||||
|
// This does not fail on regression — it hangs — so the timeout is the assertion.
|
||||||
|
func TestExpeditionAliasesDoNotWedgeTheUserLock(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@exp-alias-lock:example")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||||
|
|
||||||
|
for _, sub := range []string{"extract", "resume"} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||||
|
_ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("!expedition %s never returned: the alias re-took advUserLock", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+166
-10
@@ -3,10 +3,12 @@ package plugin
|
|||||||
// The web action queue's game-side loop — the equip queue's sibling, and the
|
// The web action queue's game-side loop — the equip queue's sibling, and the
|
||||||
// first one where the web plays the game rather than dressing the character.
|
// first one where the web plays the game rather than dressing the character.
|
||||||
//
|
//
|
||||||
// An owner, signed in on Pete, asks to pull out of a run or to take today's swing
|
// An owner, signed in on Pete, asks for something: pull out of a run, take
|
||||||
// at the Siege. Pete records the intent; we poll for it, run the real command
|
// today's swing at the Siege, set out for a zone, walk back into the run they
|
||||||
// path (the same one `!extract` and `!adventure worldboss fight` run — not a
|
// extracted from, hire the pet sitter. Pete records the intent; we poll for it,
|
||||||
// second implementation of it), and file a verdict Pete shows them.
|
// run the real command path (the same one `!extract`, `!expedition start`,
|
||||||
|
// `!resume` and the rest run — not a second implementation of it), and file a
|
||||||
|
// verdict Pete shows them.
|
||||||
//
|
//
|
||||||
// Same non-idempotency problem as equip, with higher stakes: replaying an
|
// Same non-idempotency problem as equip, with higher stakes: replaying an
|
||||||
// extraction would end a run the player had already resumed, and replaying a bout
|
// extraction would end a run the player had already resumed, and replaying a bout
|
||||||
@@ -121,10 +123,17 @@ func (p *AdventurePlugin) fulfilAdvOrder(ctx context.Context, order peteclient.A
|
|||||||
// note for Pete, or retry=true for a transient fault that should leave the order
|
// note for Pete, or retry=true for a transient fault that should leave the order
|
||||||
// pending. It records nothing and pushes nothing — the caller does both.
|
// pending. It records nothing and pushes nothing — the caller does both.
|
||||||
//
|
//
|
||||||
// Note what is NOT here: a per-user lock. Both verbs take it inside their own
|
// Note what is NOT here: a per-user lock. Almost every verb takes it inside its
|
||||||
// shared helper (performExtraction, takeSiegeBout), which is what serialises them
|
// own shared helper (performExtraction, takeSiegeBout, performResume,
|
||||||
// against the Matrix commands running the very same code. Taking it here too
|
// performBabysitPurchase), which is what serialises them against the Matrix
|
||||||
// would deadlock on a non-reentrant mutex.
|
// commands running the very same code. Taking it here too would deadlock on a
|
||||||
|
// non-reentrant mutex.
|
||||||
|
//
|
||||||
|
// The one exception is performExpeditionStart, which cannot take it — its Matrix
|
||||||
|
// caller already holds it across the whole `!expedition` switch — so
|
||||||
|
// applyWebExpeditionStart takes it instead. That asymmetry is written down in
|
||||||
|
// both places because getting it wrong does not fail loudly: it wedges the
|
||||||
|
// player's lock forever and every later adventure command from them hangs.
|
||||||
func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
switch order.Action {
|
switch order.Action {
|
||||||
case peteclient.AdvOrderExtract:
|
case peteclient.AdvOrderExtract:
|
||||||
@@ -166,6 +175,15 @@ func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrd
|
|||||||
// narration closed with, minus its markdown.
|
// narration closed with, minus its markdown.
|
||||||
return "applied", advOrderPlainText(siegeBoutFooter(bout, boss)), false
|
return "applied", advOrderPlainText(siegeBoutFooter(bout, boss)), false
|
||||||
|
|
||||||
|
case peteclient.AdvOrderExpedition:
|
||||||
|
return p.applyWebExpeditionStart(owner, order)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderResume:
|
||||||
|
return p.applyWebResume(owner, order)
|
||||||
|
|
||||||
|
case peteclient.AdvOrderBabysit:
|
||||||
|
return p.applyWebBabysit(owner, order)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Pete validates the action before it ever queues an order, so this is a
|
// Pete validates the action before it ever queues an order, so this is a
|
||||||
// contract breach, not a user mistake. Reject permanently rather than spin.
|
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||||
@@ -173,13 +191,151 @@ func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the three verbs that take arguments and spend coins ------------------------
|
||||||
|
//
|
||||||
|
// Everything below re-resolves its own arguments against the game's own tables.
|
||||||
|
// Pete only ever offers what gogobee quoted it (see pete_offers.go), but a quote
|
||||||
|
// is up to two minutes stale and is not a permission — so the zone is looked up
|
||||||
|
// again in availableZonesFor, the loadout is priced again at the real tier, and
|
||||||
|
// the fee is read again at the real level. A forged param buys nothing.
|
||||||
|
//
|
||||||
|
// All three are money moves on a retrying wire, so each hands the order guid down
|
||||||
|
// as the idempotency key. Nothing here refunds-then-retries: once a refund has
|
||||||
|
// happened the guid-keyed debit will not charge again, so a retry would hand over
|
||||||
|
// the goods for free. Every failure past the debit is therefore permanent.
|
||||||
|
|
||||||
|
// applyWebExpeditionStart sends the owner's adventurer out of town.
|
||||||
|
func (p *AdventurePlugin) applyWebExpeditionStart(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
if order.Params == nil || order.Params.Zone == "" {
|
||||||
|
return "rejected_unavailable", "That order didn't say where to.", false
|
||||||
|
}
|
||||||
|
// performExpeditionStart is the one headless twin that does NOT take the
|
||||||
|
// per-user lock (its Matrix caller already holds it across the whole
|
||||||
|
// `!expedition` switch), so this is the one order case that has to.
|
||||||
|
userMu := p.advUserLock(owner)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
c, err := LoadDnDCharacter(owner)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", true // a DB fault; nothing written, so the next tick retries cleanly
|
||||||
|
}
|
||||||
|
if c == nil || c.PendingSetup {
|
||||||
|
return "rejected_unavailable", "You don't have an adventurer yet.", false
|
||||||
|
}
|
||||||
|
zoneID, ok := resolveZoneInput(order.Params.Zone, availableZonesFor(owner, c.Level))
|
||||||
|
if !ok {
|
||||||
|
if reason := postgameLockReason(order.Params.Zone, owner, c.Level); reason != "" {
|
||||||
|
return "rejected_zone_locked", advOrderPlainText(reason), false
|
||||||
|
}
|
||||||
|
return "rejected_zone_locked", "That zone isn't open to you right now.", false
|
||||||
|
}
|
||||||
|
zone, _ := getZone(zoneID)
|
||||||
|
// An unknown loadout is refused rather than defaulted. A default here would
|
||||||
|
// spend coins on a pack size the player never picked.
|
||||||
|
loadout, ok := parseLoadoutToken(order.Params.Loadout)
|
||||||
|
if !ok {
|
||||||
|
return "rejected_unavailable", "That isn't a loadout I sell.", false
|
||||||
|
}
|
||||||
|
out, err := p.performExpeditionStart(owner, c, zone, loadoutPurchase(zone.Tier, loadout), order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errExpStartZoneLocked):
|
||||||
|
status = "rejected_zone_locked"
|
||||||
|
case errors.Is(err, errExpStartBusy):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errExpStartBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
// Everything else — still resting, a bad pack count, a start that tore
|
||||||
|
// itself down and refunded — is rejected_unavailable, and the detail line
|
||||||
|
// carries the specifics.
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Out of town, bound for %s, with the %s loadout: %d coins, about %d days of provisions.",
|
||||||
|
out.Zone.Display, loadoutName(loadout), out.Cost, out.Days), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebResume walks the owner back into the run they extracted from.
|
||||||
|
func (p *AdventurePlugin) applyWebResume(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
// The loadout is required here, unlike in Matrix: an empty one asks
|
||||||
|
// performResume for the pick-a-loadout prompt, which is a DM, not a verdict.
|
||||||
|
if order.Params == nil || order.Params.Loadout == "" {
|
||||||
|
return "rejected_unavailable", "That order didn't say what to pack.", false
|
||||||
|
}
|
||||||
|
if _, ok := parseLoadoutToken(order.Params.Loadout); !ok {
|
||||||
|
return "rejected_unavailable", "That isn't a loadout I sell.", false
|
||||||
|
}
|
||||||
|
out, err := p.performResume(owner, order.Params.Loadout, order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
var refusal advRefusal
|
||||||
|
if !errors.As(err, &refusal) {
|
||||||
|
// Not a refusal at all — a DB fault reading expedition state. Nothing
|
||||||
|
// has been written, so leave it pending.
|
||||||
|
slog.Warn("orders: resume failed", "order", order.GUID, "user", owner, "err", err)
|
||||||
|
return "", "", true
|
||||||
|
}
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errResumeBusy):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errResumeNothing), errors.Is(err, errResumeLapsed):
|
||||||
|
status = "rejected_nothing_to_resume"
|
||||||
|
case errors.Is(err, errResumeBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
return "applied", fmt.Sprintf(
|
||||||
|
"Back into %s on day %d, re-outfitted for %d coins.",
|
||||||
|
out.Zone.Display, out.Day, out.Purchase.Cost()), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebBabysit engages the pet sitter for a week or a month.
|
||||||
|
func (p *AdventurePlugin) applyWebBabysit(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) {
|
||||||
|
// The two durations the game sells. Anything else is a contract breach rather
|
||||||
|
// than a user mistake, since Pete offers exactly these two.
|
||||||
|
days := 0
|
||||||
|
if order.Params != nil {
|
||||||
|
days = order.Params.Days
|
||||||
|
}
|
||||||
|
if days != 7 && days != 30 {
|
||||||
|
return "rejected_unavailable", "The sitter works by the week or by the month.", false
|
||||||
|
}
|
||||||
|
out, err := p.performBabysitPurchase(owner, days, order.GUID)
|
||||||
|
if err != nil {
|
||||||
|
status := "rejected_unavailable"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errBabysitActive):
|
||||||
|
status = "rejected_busy"
|
||||||
|
case errors.Is(err, errBabysitBroke):
|
||||||
|
status = "rejected_insufficient_funds"
|
||||||
|
}
|
||||||
|
return status, advOrderPlainText(err.Error()), false
|
||||||
|
}
|
||||||
|
label := "a week"
|
||||||
|
if out.Days == 30 {
|
||||||
|
label = "a month"
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("Sitter engaged for %s, %d coins.", label, out.Cost)
|
||||||
|
if out.PetName != "" {
|
||||||
|
note += fmt.Sprintf(" %s is in good hands.", out.PetName)
|
||||||
|
}
|
||||||
|
return "applied", note, false
|
||||||
|
}
|
||||||
|
|
||||||
// advOrderPlainText strips the Matrix markdown out of a line reused as a web
|
// advOrderPlainText strips the Matrix markdown out of a line reused as a web
|
||||||
// verdict. Pete renders the detail as text, so asterisks would show up literally.
|
// verdict. Pete renders the detail as text, so asterisks and backticks would show
|
||||||
|
// up literally. The command hints inside those backticks stay — a verdict that
|
||||||
|
// says to type `!expedition abandon` is telling the truth about where the other
|
||||||
|
// door is, and the web has no button for it yet.
|
||||||
func advOrderPlainText(s string) string {
|
func advOrderPlainText(s string) string {
|
||||||
out := make([]rune, 0, len(s))
|
out := make([]rune, 0, len(s))
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
switch r {
|
switch r {
|
||||||
case '*':
|
case '*', '`':
|
||||||
continue
|
continue
|
||||||
case '\n':
|
case '\n':
|
||||||
out = append(out, ' ')
|
out = append(out, ' ')
|
||||||
|
|||||||
@@ -172,3 +172,210 @@ func TestAdvOrderPlainText(t *testing.T) {
|
|||||||
t.Fatalf("plain text lost the facts: %q", got)
|
t.Fatalf("plain text lost the facts: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── W5b: the three verbs that take arguments and spend coins ───────────────────
|
||||||
|
|
||||||
|
// webOrderTestChar builds a character solvent enough to outfit an expedition.
|
||||||
|
func webOrderTestChar(t *testing.T, uid id.UserID, level int, coins float64) *AdventurePlugin {
|
||||||
|
t.Helper()
|
||||||
|
if err := createAdvCharacter(uid, "weborder"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := &DnDCharacter{
|
||||||
|
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||||||
|
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||||
|
HPMax: 30, HPCurrent: 30, ArmorClass: 14,
|
||||||
|
}
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
euro := &EuroPlugin{}
|
||||||
|
euro.ensureBalance(uid)
|
||||||
|
if coins > 0 {
|
||||||
|
euro.Credit(uid, coins, "test bankroll")
|
||||||
|
}
|
||||||
|
return &AdventurePlugin{euro: euro}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forged zone must buy nothing. Pete only ever offers what gogobee quoted it,
|
||||||
|
// but a quote is a stale snapshot and not a permission — so the order path
|
||||||
|
// re-resolves against availableZonesFor and refuses anything that isn't there.
|
||||||
|
func TestWebExpeditionStartReResolvesTheZone(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-forged:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "forged-zone", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: "dragons_lair", Loadout: "lean"},
|
||||||
|
})
|
||||||
|
if retry {
|
||||||
|
t.Fatal("a locked zone asked for a retry; it must be terminal")
|
||||||
|
}
|
||||||
|
if status != "rejected_zone_locked" {
|
||||||
|
t.Fatalf("status = %q, want rejected_zone_locked", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a refused departure moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
if exp, _ := getActiveExpedition(uid); exp != nil {
|
||||||
|
t.Fatal("a refused departure started an expedition anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown loadout is refused, never defaulted. Defaulting would spend coins on
|
||||||
|
// a pack size the player never picked.
|
||||||
|
func TestWebExpeditionStartRefusesAnUnknownLoadout(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-loadout:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "bad-loadout", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "enormous"},
|
||||||
|
})
|
||||||
|
if status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("status = %q, want rejected_unavailable", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a refused loadout moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The money test that matters: a re-offered order (verdict-ack lost before the
|
||||||
|
// ledger stamped it) must not charge twice, and must not answer "you're already
|
||||||
|
// on an expedition" for the expedition it just started.
|
||||||
|
func TestWebExpeditionStartChargesOnceOnAReoffer(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-start-idem:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
order := peteclient.AdvOrder{
|
||||||
|
GUID: "start-once", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "lean"},
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, order)
|
||||||
|
if retry || status != "applied" {
|
||||||
|
t.Fatalf("first apply = %q retry=%v, want applied", status, retry)
|
||||||
|
}
|
||||||
|
afterFirst := p.euro.GetBalance(uid)
|
||||||
|
if afterFirst >= before {
|
||||||
|
t.Fatalf("outfitting cost nothing: %.0f -> %.0f", before, afterFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The re-offer. applyAdvOrder is reached directly here on purpose: the
|
||||||
|
// adv_applied_orders ledger would normally short-circuit it, and this asserts
|
||||||
|
// the layer *underneath* that guard is safe too.
|
||||||
|
status, _, retry = p.applyAdvOrder(uid, order)
|
||||||
|
if retry {
|
||||||
|
t.Fatal("the re-offer asked for a retry")
|
||||||
|
}
|
||||||
|
if status != "applied" {
|
||||||
|
t.Fatalf("re-offer = %q, want applied — the settled debit is what tells a "+
|
||||||
|
"replay apart from a player who really is already out", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != afterFirst {
|
||||||
|
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Babysit: same replay contract, plus the two durations are the only two sold.
|
||||||
|
func TestWebBabysitChargesOnceAndSellsTwoDurations(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-sitter:example.org")
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||||
|
GUID: "sitter-odd", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 3},
|
||||||
|
})
|
||||||
|
if status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("3-day sitter = %q, want rejected_unavailable", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := peteclient.AdvOrder{
|
||||||
|
GUID: "sitter-once", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 7},
|
||||||
|
}
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
|
||||||
|
t.Fatalf("hire = %q, want applied", status)
|
||||||
|
}
|
||||||
|
afterFirst := p.euro.GetBalance(uid)
|
||||||
|
if afterFirst >= before {
|
||||||
|
t.Fatalf("the sitter worked for free: %.0f -> %.0f", before, afterFirst)
|
||||||
|
}
|
||||||
|
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
|
||||||
|
t.Fatalf("re-offer = %q, want applied", status)
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != afterFirst {
|
||||||
|
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal must never come back as retry: a retried refusal never reaches a
|
||||||
|
// verdict, so the order parks and the strip says "asked for…" forever.
|
||||||
|
func TestWebMoneyVerbRefusalsAreTerminal(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-broke:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 0)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
order peteclient.AdvOrder
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"broke departure", peteclient.AdvOrder{
|
||||||
|
GUID: "broke-1", Action: peteclient.AdvOrderExpedition,
|
||||||
|
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "heavy"},
|
||||||
|
}, "rejected_insufficient_funds"},
|
||||||
|
{"nothing to resume", peteclient.AdvOrder{
|
||||||
|
GUID: "resume-1", Action: peteclient.AdvOrderResume,
|
||||||
|
Params: &peteclient.AdvOrderParams{Loadout: "lean"},
|
||||||
|
}, "rejected_nothing_to_resume"},
|
||||||
|
{"broke sitter", peteclient.AdvOrder{
|
||||||
|
GUID: "broke-2", Action: peteclient.AdvOrderBabysit,
|
||||||
|
Params: &peteclient.AdvOrderParams{Days: 30},
|
||||||
|
}, "rejected_insufficient_funds"},
|
||||||
|
} {
|
||||||
|
status, detail, retry := p.applyAdvOrder(uid, tc.order)
|
||||||
|
if retry {
|
||||||
|
t.Fatalf("%s asked for a retry; it must be terminal", tc.name)
|
||||||
|
}
|
||||||
|
if status != tc.want {
|
||||||
|
t.Fatalf("%s = %q, want %q (detail %q)", tc.name, status, tc.want, detail)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(detail, "*`") {
|
||||||
|
t.Fatalf("%s verdict still carries Matrix markdown: %q", tc.name, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An order with no params at all is a contract breach, not a user mistake, and
|
||||||
|
// must be refused rather than defaulted into spending money.
|
||||||
|
func TestWebMoneyVerbsRefuseMissingParams(t *testing.T) {
|
||||||
|
setupEmptyTestDB(t)
|
||||||
|
uid := id.UserID("@web-noparams:example.org")
|
||||||
|
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||||
|
p := webOrderTestChar(t, uid, 2, 100000)
|
||||||
|
|
||||||
|
before := p.euro.GetBalance(uid)
|
||||||
|
for _, action := range []string{
|
||||||
|
peteclient.AdvOrderExpedition, peteclient.AdvOrderResume, peteclient.AdvOrderBabysit,
|
||||||
|
} {
|
||||||
|
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "np-" + action, Action: action})
|
||||||
|
if retry || status != "rejected_unavailable" {
|
||||||
|
t.Fatalf("%s with no params = %q retry=%v, want rejected_unavailable", action, status, retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if after := p.euro.GetBalance(uid); after != before {
|
||||||
|
t.Fatalf("a paramless order moved money: %.0f -> %.0f", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,11 @@ func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailS
|
|||||||
if p.euro != nil {
|
if p.euro != nil {
|
||||||
pd.Balance = p.euro.GetBalance(uid)
|
pd.Balance = p.euro.GetBalance(uid)
|
||||||
}
|
}
|
||||||
|
// W5b: what this owner may ask for from the web, priced. See pete_offers.go
|
||||||
|
// on why a quote is not a permission.
|
||||||
|
pd.Zones = zoneOffersFor(uid)
|
||||||
|
pd.Resume = resumeOfferFor(uid)
|
||||||
|
pd.Babysit = babysitOfferFor(adv)
|
||||||
snap.Players = append(snap.Players, pd)
|
snap.Players = append(snap.Players, pd)
|
||||||
}
|
}
|
||||||
return snap, nil
|
return snap, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user