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

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

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

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

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

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 19:47:45 -07:00
parent fff2aa79d0
commit f73ab56ac8
9 changed files with 1050 additions and 162 deletions
+85 -10
View File
@@ -540,13 +540,13 @@ type RealmOccupant struct {
// recovered by gogobee at push time from the run history, which is why this is // recovered by gogobee at push time from the run history, which is why this is
// pushed rather than derived on Pete. // pushed rather than derived on Pete.
type RealmFirst struct { type RealmFirst struct {
Kind string `json:"kind"` // "zone" | "treasure" Kind string `json:"kind"` // "zone" | "treasure"
Target string `json:"target"` // the zone id or treasure key Target string `json:"target"` // the zone id or treasure key
Display string `json:"display"` // the human name for it Display string `json:"display"` // the human name for it
Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone" Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone"
Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable
Token string `json:"token,omitempty"` // board token; empty when opted out Token string `json:"token,omitempty"` // board token; empty when opted out
AtUnix int64 `json:"at_unix"` // when the realm first saw it AtUnix int64 `json:"at_unix"` // when the realm first saw it
} }
// RealmStanding is one adventurer's line on the board. Every number here is a // RealmStanding is one adventurer's line on the board. Every number here is a
@@ -631,6 +631,63 @@ type PlayerDetail struct {
// No omitempty: a €0 balance is a real, informative fact (a broke player), not // 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
+89 -20
View File
@@ -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
balance := p.euro.GetBalance(char.UserID) if p.euro == nil {
if balance < float64(totalCost) { return babysitOutcome{}, refuseAdv(errBabysitFailed, "Coin system unavailable — try again later.")
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))) }
// 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)
if balance < float64(totalCost) {
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)
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund") if idemKey != "" {
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.") 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")
}
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)
} }
+229 -82
View File
@@ -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)
} }
+115 -40
View File
@@ -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 {
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", if balance := p.euro.GetBalance(uid); balance < cost {
int(cost), balance)) return resumeOutcome{}, refuseAdv(errResumeBroke,
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
int(cost), balance)
}
} }
if !p.euro.Debit(ctx.Sender, cost, "expedition resume outfitting: "+string(exp.ZoneID)) { debit := func(why string) bool { return p.euro.Debit(uid, cost, why) }
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).") 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)
}
}
}
+129
View File
@@ -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
View File
@@ -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, ' ')
+207
View File
@@ -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)
}
}
+5
View File
@@ -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