From f73ab56ac81fb5211cfced6d68e7d2e1d23973a6 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:47:45 -0700 Subject: [PATCH] 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 --- internal/peteclient/client.go | 95 +++++- internal/plugin/adventure_babysit.go | 109 ++++-- internal/plugin/dnd_expedition_cmd.go | 311 +++++++++++++----- internal/plugin/dnd_expedition_extract.go | 155 ++++++--- .../plugin/dnd_expedition_extract_test.go | 25 ++ internal/plugin/pete_offers.go | 129 ++++++++ internal/plugin/pete_orders.go | 176 +++++++++- internal/plugin/pete_orders_test.go | 207 ++++++++++++ internal/plugin/pete_roster.go | 5 + 9 files changed, 1050 insertions(+), 162 deletions(-) create mode 100644 internal/plugin/pete_offers.go diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index af8c5a7..2536a61 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -540,13 +540,13 @@ type RealmOccupant struct { // recovered by gogobee at push time from the run history, which is why this is // pushed rather than derived on Pete. type RealmFirst struct { - Kind string `json:"kind"` // "zone" | "treasure" - Target string `json:"target"` // the zone id or treasure key - Display string `json:"display"` // the human name for it - Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone" - Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable - Token string `json:"token,omitempty"` // board token; empty when opted out - AtUnix int64 `json:"at_unix"` // when the realm first saw it + Kind string `json:"kind"` // "zone" | "treasure" + Target string `json:"target"` // the zone id or treasure key + Display string `json:"display"` // the human name for it + Tier int `json:"tier,omitempty"` // zone tier, when kind is "zone" + Holder string `json:"holder,omitempty"` // character name, empty when unrecoverable + Token string `json:"token,omitempty"` // board token; empty when opted out + AtUnix int64 `json:"at_unix"` // when the realm first saw it } // RealmStanding is one adventurer's line on the board. Every number here is a @@ -631,6 +631,63 @@ type PlayerDetail struct { // No omitempty: a €0 balance is a real, informative fact (a broke player), not // an absent one — dropping it would let the confirm dialog show a stale amount. Balance float64 `json:"balance"` + // Zones is where this adventurer may go right now, priced. It is the offer + // list behind the web's "send on expedition" picker: level gating and the T6 + // postgame gate are resolved here, so a zone the player cannot enter is simply + // absent rather than shown and then refused. Empty while they are already out. + Zones []ZoneOffer `json:"zones,omitempty"` + // Resume is the extracted expedition waiting to be walked back into, priced + // the same way. Absent when there is nothing to resume. + Resume *ResumeOffer `json:"resume,omitempty"` + // Babysit is the pet-care subscription's standing and price. Always present + // for a live adventurer: "you already have one" is as useful to the page as a + // price is. + Babysit *BabysitOffer `json:"babysit,omitempty"` +} + +// ZoneOffer is one place the owner may set out for, with what the trip costs. +// Pete renders these and does no arithmetic — the prices are the game's, quoted +// at push time, and gogobee re-quotes them for real when the order lands. +type ZoneOffer struct { + ID string `json:"id"` + Display string `json:"display"` + Tier int `json:"tier"` + Hook string `json:"hook,omitempty"` + Postgame bool `json:"postgame,omitempty"` + Loadouts []LoadoutOffer `json:"loadouts,omitempty"` +} + +// LoadoutOffer is one supply preset for a zone: what it is called, what it +// costs, and roughly how long it lasts. Key is the token the order carries back. +type LoadoutOffer struct { + Key string `json:"key"` // lean|balanced|heavy + Name string `json:"name"` + Blurb string `json:"blurb,omitempty"` + Cost int `json:"cost"` + Days int `json:"days"` // provisions at the zone's daily burn +} + +// ResumeOffer is the extracted expedition the owner can still walk back into, +// with the same priced loadouts as a fresh departure. ExpiresAt is the end of +// the seven-day window, so the page can say how long is left rather than just +// that there is a way back. +type ResumeOffer struct { + ZoneID string `json:"zone_id"` + Display string `json:"display"` + Tier int `json:"tier"` + Day int `json:"day"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Loadouts []LoadoutOffer `json:"loadouts,omitempty"` +} + +// BabysitOffer is the sitter's standing and price. WeekCost/MonthCost are the +// two durations the game sells; they scale with level, which is why they are +// pushed rather than hardcoded on Pete. +type BabysitOffer struct { + Active bool `json:"active"` + ExpiresAt int64 `json:"expires_at,omitempty"` + WeekCost int `json:"week_cost"` + MonthCost int `json:"month_cost"` } // EquipSlotView is one of the 5 standard equipment slots, carrying what the web @@ -1007,15 +1064,33 @@ type AdvOrder struct { OwnerLocalpart string `json:"owner_localpart"` Token string `json:"token"` CharacterName string `json:"character_name"` - Action string `json:"action"` // extract / siege_join + Action string `json:"action"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` + // Params is the verb's arguments, and only the verbs that take any carry it: + // which zone, which supply loadout, how many days of sitting. It never names + // an adventurer — that still comes from the session on Pete's side — and + // every field in it is re-resolved against the game's own tables before it + // means anything, so a forged zone or a forged price buys nothing. + Params *AdvOrderParams `json:"params,omitempty"` +} + +// AdvOrderParams is the union of every verb's arguments, flat rather than +// per-verb because there are three of them and each reads one or two fields. +// Anything a verb does not read is ignored rather than rejected. +type AdvOrderParams struct { + Zone string `json:"zone,omitempty"` // zone id, for expedition_start + Loadout string `json:"loadout,omitempty"` // lean|balanced|heavy, for expedition_start / resume + Days int `json:"days,omitempty"` // 7 or 30, for babysit } // Action names, the wire contract's half of storage.AdvAction* on Pete. const ( - AdvOrderExtract = "extract" - AdvOrderSiegeJoin = "siege_join" + AdvOrderExtract = "extract" + AdvOrderSiegeJoin = "siege_join" + AdvOrderExpedition = "expedition_start" + AdvOrderResume = "expedition_resume" + AdvOrderBabysit = "babysit" ) // PendingOrders asks Pete for web actions waiting on us. A Pete predating the diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 83e7047..9084d3d 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "log/slog" "strings" @@ -100,33 +101,84 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro } } -func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the ways hiring a sitter can be refused, so the web action queue +// (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() defer userMu.Unlock() - char, err := loadAdvCharacter(ctx.Sender) + char, err := loadAdvCharacter(uid) 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 { - 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 { - 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)) totalCost := daily * days - balance := p.euro.GetBalance(char.UserID) - 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))) + 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) + 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") { - return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.") + debited := false + 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) @@ -138,23 +190,40 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er if err := saveAdvCharacter(char); err != nil { slog.Error("babysit: failed to save character", "user", char.UserID, "err", err) - 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.") + 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") + } + return babysitOutcome{}, refuseAdv(errBabysitFailed, "Something went wrong activating the service. Your gold has been refunded.") } if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil { 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." if char.HasPet() { 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"+ "Duration: %s (%d days)\n"+ @@ -162,7 +231,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er "%s\n"+ "Camp safety: standard camps now rest like fortified ones\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) } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index eead47c..254aec9 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "log/slog" "math" @@ -32,6 +33,23 @@ import ( // in E1e. !advance / !search / !rest / !extract are out-of-scope for E1. 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.Lock() 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).") } - args = strings.TrimSpace(args) - sub, rest := splitFirstWord(args) switch strings.ToLower(sub) { case "": // 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) case "dismiss": return p.expeditionCmdDismiss(ctx) - case "extract": - return p.handleExtractCmd(ctx, "") - case "resume": - return p.handleResumeCmd(ctx, rest) case "map", "m": return p.handleExpeditionMapCmd(ctx, "") case "run", "explore", "advance": @@ -332,83 +344,14 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter if err != nil { 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()) - 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") + out, err := p.performExpeditionStart(ctx.Sender, c, zoneForCaps, purchase, "") 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()) } - markActedToday(ctx.Sender) + zone, supplies, startLine := out.Zone, out.Supplies, out.StartLine var b strings.Builder 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()) } +// ── 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: // 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 @@ -442,10 +561,38 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter // 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. 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 { return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.") } 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 // 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) // 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).") } exp, err := startExpedition(uid, zone.ID, "", supplies) if err != nil { // 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) } @@ -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 // linked run, harvest and rooms can't function. _ = 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) } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 0c2bcb7..2019bc4 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -447,76 +447,136 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { // ── !resume command ───────────────────────────────────────────────────────── -func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the ways going back in can be refused. Same contract as +// 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() defer userMu.Unlock() - c, err := LoadDnDCharacter(ctx.Sender) + c, err := LoadDnDCharacter(uid) 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 { - 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) 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`.", - 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.", - zone.Display, existing.CurrentDay)) + zone.Display, existing.CurrentDay) } - exp, err := getResumableExpedition(ctx.Sender) + exp, err := getResumableExpedition(uid) 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 { - return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start ` to begin a new one.") + return resumeOutcome{}, refuseAdv(errResumeNothing, + "No extracted expedition to resume. Use `!expedition start ` to begin a new one.") } if extractionLapsed(exp, time.Now().UTC()) { // 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. _ = 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.") } - resumeZone, _ := getZone(exp.ZoneID) + zone, _ := getZone(exp.ZoneID) // D5-b: prompt for a preset loadout on empty args. - if strings.TrimSpace(args) == "" { - return p.SendDM(ctx.Sender, renderLoadoutPrompt(resumeZone, "resume")) + if strings.TrimSpace(loadoutTok) == "" { + 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 { - 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 { - return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) + if err := purchase.Validate(zone.Tier); err != nil { + return resumeOutcome{}, refuseAdv(errResumeBadPacks, "Invalid pack selection: %s", err.Error()) } cost := float64(purchase.Cost()) 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 { - return p.SendDM(ctx.Sender, fmt.Sprintf( - "Not enough coins. Outfitting costs **%d** but you have **%.0f**.", - int(cost), balance)) + paid := idemKey != "" && p.euro.HasExternalTx(idemKey) + if !paid { + if balance := p.euro.GetBalance(uid); balance < cost { + 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)) { - 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) if err := resumeExpedition(exp.ID, supplies); err != nil { - p.euro.Credit(ctx.Sender, cost, "expedition resume refund") - return p.SendDM(ctx.Sender, "Couldn't resume: "+err.Error()) + refund("expedition resume refund", "") + return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't resume: %s", err.Error()) } exp.Status = ExpeditionStatusActive exp.Supplies = supplies @@ -527,25 +587,40 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error exp.RegionState[regionStateRegionRuns] = map[string]string{} _ = persistRegionState(exp) if _, err := ensureRegionRun(exp, c.Level); err != nil { - p.euro.Credit(ctx.Sender, cost, "expedition resume refund (run-spawn failed)") - return p.SendDM(ctx.Sender, "Couldn't outfit the resumed region: "+err.Error()) + refund("expedition resume refund (run-spawn failed)", ":region") + return resumeOutcome{}, refuseAdv(errResumeFailed, "Couldn't outfit the resumed region: %s", err.Error()) } line := flavor.Pick(flavor.ExpeditionResume) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "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 b.WriteString(fmt.Sprintf("🚪 **Expedition resumed — %s, Day %d**\n\n", - zone.Display, exp.CurrentDay)) - if line != "" { - b.WriteString(line) + out.Zone.Display, out.Day)) + if out.Line != "" { + b.WriteString(out.Line) b.WriteString("\n\n") } b.WriteString(fmt.Sprintf("**Re-outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n", - supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost())) - b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", exp.ThreatLevel)) - if exp.TemporalStack != 0 { - b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", exp.TemporalStack)) + out.Supplies.Max, out.Purchase.StandardPacks, out.Purchase.DeluxePacks, out.Purchase.Cost())) + b.WriteString(fmt.Sprintf("**Threat:** %d / 100 (resumed at extraction value)\n", out.Threat)) + if out.Stack != 0 { + b.WriteString(fmt.Sprintf("**Zone stack:** %d (resumed)\n", out.Stack)) } b.WriteString("\nUse `!expedition status` for the daily briefing.") return p.SendDM(ctx.Sender, b.String()) diff --git a/internal/plugin/dnd_expedition_extract_test.go b/internal/plugin/dnd_expedition_extract_test.go index aae426b..3c3624b 100644 --- a/internal/plugin/dnd_expedition_extract_test.go +++ b/internal/plugin/dnd_expedition_extract_test.go @@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) { 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) + } + } +} diff --git a/internal/plugin/pete_offers.go b/internal/plugin/pete_offers.go new file mode 100644 index 0000000..d2ef309 --- /dev/null +++ b/internal/plugin/pete_offers.go @@ -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 +} diff --git a/internal/plugin/pete_orders.go b/internal/plugin/pete_orders.go index 5eef51a..3c73377 100644 --- a/internal/plugin/pete_orders.go +++ b/internal/plugin/pete_orders.go @@ -3,10 +3,12 @@ package plugin // 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. // -// An owner, signed in on Pete, asks to pull out of a run or to take today's swing -// at the Siege. Pete records the intent; we poll for it, run the real command -// path (the same one `!extract` and `!adventure worldboss fight` run — not a -// second implementation of it), and file a verdict Pete shows them. +// An owner, signed in on Pete, asks for something: pull out of a run, take +// today's swing at the Siege, set out for a zone, walk back into the run they +// extracted from, hire the pet sitter. Pete records the intent; we poll for it, +// 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 // 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 // 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 -// shared helper (performExtraction, takeSiegeBout), which is what serialises them -// against the Matrix commands running the very same code. Taking it here too -// would deadlock on a non-reentrant mutex. +// Note what is NOT here: a per-user lock. Almost every verb takes it inside its +// own shared helper (performExtraction, takeSiegeBout, performResume, +// performBabysitPurchase), which is what serialises them against the Matrix +// 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) { switch order.Action { case peteclient.AdvOrderExtract: @@ -166,6 +175,15 @@ func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrd // narration closed with, minus its markdown. 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: // 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. @@ -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 -// 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 { out := make([]rune, 0, len(s)) for _, r := range s { switch r { - case '*': + case '*', '`': continue case '\n': out = append(out, ' ') diff --git a/internal/plugin/pete_orders_test.go b/internal/plugin/pete_orders_test.go index 91bd49e..371343f 100644 --- a/internal/plugin/pete_orders_test.go +++ b/internal/plugin/pete_orders_test.go @@ -172,3 +172,210 @@ func TestAdvOrderPlainText(t *testing.T) { 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) + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 7bfcd59..3cfb29d 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -225,6 +225,11 @@ func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailS if p.euro != nil { 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) } return snap, nil