diff --git a/internal/db/db.go b/internal/db/db.go index 44566d1..ccd8695 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1807,6 +1807,21 @@ CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id, CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at); CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at); +-- The web equip queue's idempotency ledger. Pete records an owner's equip/unequip +-- intent and we poll it; the guid stamped here is what makes a re-offered order a +-- no-op. Mischief can lean on its contract row for the same job, but an equip +-- opens no durable object of its own — worse, the underlying action is NOT +-- idempotent (equipping consumes an inventory row, unequip mints a fresh one), so +-- without this a poll loop whose verdict-ack was lost would re-run the equip and +-- double-move the item. We record the guid the instant the mutation lands and +-- short-circuit on it before touching anything on a re-offer. +CREATE TABLE IF NOT EXISTS equip_applied_orders ( + guid TEXT PRIMARY KEY, + status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer + detail TEXT NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + -- Babysitting Service CREATE TABLE IF NOT EXISTS adventure_babysit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 9a50285..606e8d0 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -398,6 +398,13 @@ type PlayerDetail struct { // with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding // what to wear needs to see the difference. type ItemView struct { + // ID is the adventure_inventory row id, sent only for a backpack item the + // magic-item equip path will accept — so a non-zero ID is also the signal that + // this item can be equipped from the web. Worn and vault rows carry none: a + // worn item unequips by slot, and a vault item can't be equipped at all. Pete + // round-trips this id in an equip order; the table is AUTOINCREMENT, so a stale + // id (item already moved) misses cleanly rather than hitting the wrong row. + ID int64 `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` Tier int `json:"tier"` @@ -618,6 +625,59 @@ func ClaimMischief(ctx context.Context, guid, status, detail string) error { return std.post(ctx, "/api/mischief/claim", payload) } +// --------------------------------------------------------------------------- +// The equip queue's reverse pipe +// +// An owner asks, on their own detail page, to wear or take off an item. Pete +// records the intent; we poll for it, run the real equip against our own +// equipment tables, and file a verdict. Same shape as mischief — Pete has no +// route in — but with one crucial difference: the game action is NOT naturally +// idempotent (equipping consumes an inventory row and regenerates it on +// unequip), so re-running a drained order would double-move items. The poller +// therefore short-circuits on the order guid before it mutates, the way +// placeWebMischief does on its contract; the guid is still the end-to-end key, +// but here it guards a non-idempotent action rather than riding a naturally +// idempotent one. +// --------------------------------------------------------------------------- + +// EquipOrder is one equip/unequip as Pete describes it. owner_localpart is the +// Matrix localpart of the character to dress; item_id is the adventure_inventory +// row id for an equip (0 for an unequip, which keys on slot). character_name and +// item_name are display copy Pete froze at order time; we don't need them. +type EquipOrder struct { + GUID string `json:"guid"` + OwnerLocalpart string `json:"owner_localpart"` + ItemID int64 `json:"item_id"` + ItemName string `json:"item_name"` + Slot string `json:"slot"` + Action string `json:"action"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +// PendingEquip asks Pete for equip orders waiting on us. A Pete predating the +// queue answers 404, surfaced here as an error the poll loop logs quietly. +func PendingEquip(ctx context.Context) ([]EquipOrder, error) { + if !Enabled() { + return nil, nil + } + var out []EquipOrder + if err := std.getJSON(ctx, "/api/equip/pending", &out); err != nil { + return nil, err + } + return out, nil +} + +// VerdictEquip files our verdict on an equip order. Idempotent on Pete, so a +// retried verdict is safe; the verdict rides this call directly. +func VerdictEquip(ctx context.Context, guid, status, detail string) error { + payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail}) + if err != nil { + return err + } + return std.post(ctx, "/api/equip/verdict", payload) +} + // getJSON does a bearer-authed GET and decodes the body. func (c *Client) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index c38cc17..877a826 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -291,6 +291,7 @@ func (p *AdventurePlugin) Init() error { go p.expeditionBoredomTicker() go p.mischiefTicker() go p.peteMischiefTicker() + go p.peteEquipTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() diff --git a/internal/plugin/magic_items_gameplay.go b/internal/plugin/magic_items_gameplay.go index b429012..fac1f79 100644 --- a/internal/plugin/magic_items_gameplay.go +++ b/internal/plugin/magic_items_gameplay.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "log/slog" "math/rand/v2" @@ -559,6 +560,133 @@ func magicItemEffectSummary(mi MagicItem) string { return strings.Join(parts, ", ") } +// The equip mutation, message-free, shared by the DM resolver above and the web +// equip queue (pete_equip.go). Splitting it out is deliberate: the ordering below +// — evict occupant, then remove-from-inventory *before* equip, with a rollback if +// equip fails — is the whole defence against item duplication, and a second copy +// of it living in the web path is exactly where that defence would silently rot. +// One implementation, two callers. + +// errItemNotEquippable is a *permanent* refusal (not a magic item, or a slotless +// curio) — the caller should reject the request, not retry it. Every other error +// from applyMagicEquip is a transient DB fault the caller may retry. +var errItemNotEquippable = errors.New("magic-item: not equippable") + +// errSlotEmpty is applyMagicUnequip's permanent refusal: nothing is in that slot. +var errSlotEmpty = errors.New("magic-item: slot empty") + +// magicEquipOutcome is what an equip did, for the caller to narrate. BondsBefore +// is the attunement count *after* any swap-eviction and *before* this item, so a +// caller reporting "bonded (N/3)" adds one. +type magicEquipOutcome struct { + Effective MagicItem // the item as worn, tempering folded in + SwappedBack string // name of the occupant sent back to inventory, or "" + Bonded bool // this item took a bond just now + AtCap bool // worn but inert: it wants a bond and all 3 are used + BondsBefore int // bonds in use before this item was worn + Healed []string // stragglers a freed slot let bond, post-equip +} + +// applyMagicEquip wears one inventory item, preserving the anti-duplication +// ordering. It mutates the equipment tables and sends nothing. +func applyMagicEquip(userID id.UserID, it AdvItem) (magicEquipOutcome, error) { + mi, ok := magicItemFromAdvItem(it) + if !ok || mi.Slot == "" { + return magicEquipOutcome{}, errItemNotEquippable + } + equipped, err := loadEquippedMagicItems(userID) + if err != nil { + return magicEquipOutcome{}, err + } + + // Return whatever occupies that slot to inventory at full value, and drop it + // from the local map so the bond count below reflects the post-swap state. + var swappedBack string + if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" { + back := magicItemSellAt(prev.Item, prev.Temper) + back.SkillSource = "magic_item:" + prev.Item.ID + if err := addAdvInventoryItem(userID, back); err != nil { + return magicEquipOutcome{}, err + } + swappedBack = prev.Item.Name + delete(equipped, mi.Slot) + } + + bondsBefore := countAttunedMagicItems(equipped) + bonded, atCap := false, false + if mi.Attunement { + if bondsBefore >= dndMagicItemAttuneLimit { + atCap = true + } else { + bonded = true + } + } + + // Remove the inventory row FIRST, then equip; if equip fails, restore the row. + // The reverse order left a transient failure with the item both worn and in the + // pack — a free duplicate. + if err := removeAdvInventoryItem(it.ID); err != nil { + slog.Error("magic-item: failed to remove from inventory before equip", + "user", userID, "item", mi.ID, "err", err) + return magicEquipOutcome{}, err + } + if err := equipMagicItem(userID, mi.Slot, mi.ID, bonded, it.Temper); err != nil { + restored := magicItemSellAt(mi, it.Temper) + restored.Value = it.Value + restored.SkillSource = "magic_item:" + mi.ID + if rbErr := addAdvInventoryItem(userID, restored); rbErr != nil { + slog.Error("magic-item: equip failed AND inventory rollback failed", + "user", userID, "item", mi.ID, "equip_err", err, "rollback_err", rbErr) + } + return magicEquipOutcome{}, err + } + + // Swapping the occupant out may have freed a bond slot — light up any straggler. + healed, _ := reconcileMagicAttunements(userID) + return magicEquipOutcome{ + Effective: temperedItem(mi, it.Temper), + SwappedBack: swappedBack, + Bonded: bonded, + AtCap: atCap, + BondsBefore: bondsBefore, + Healed: healed, + }, nil +} + +// magicUnequipOutcome is what an unequip did, for the caller to narrate. +type magicUnequipOutcome struct { + Item MagicItem // the item taken off, as it was worn + Healed []string // stragglers the freed bond slot let bond +} + +// applyMagicUnequip takes the item off a slot and returns it to inventory, +// mirroring the equip ordering (destructive op first, restore on failure). Sends +// nothing. +func applyMagicUnequip(userID id.UserID, slot DnDSlot) (magicUnequipOutcome, error) { + equipped, err := loadEquippedMagicItems(userID) + if err != nil { + return magicUnequipOutcome{}, err + } + e, ok := equipped[slot] + if !ok || e.Item.ID == "" { + return magicUnequipOutcome{}, errSlotEmpty + } + if err := unequipMagicItem(userID, slot); err != nil { + return magicUnequipOutcome{}, err + } + back := magicItemSellAt(e.Item, e.Temper) + back.SkillSource = "magic_item:" + e.Item.ID + if err := addAdvInventoryItem(userID, back); err != nil { + if rbErr := equipMagicItem(userID, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil { + slog.Error("magic-item: unequip failed AND re-equip rollback failed", + "user", userID, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr) + } + return magicUnequipOutcome{}, err + } + healed, _ := reconcileMagicAttunements(userID) + return magicUnequipOutcome{Item: e.Effective(), Healed: healed}, nil +} + func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { // Self-heal first: bond any worn item stranded inert while a slot is free // (e.g. equipped at cap, then a bond slot opened). This is the only path a @@ -639,86 +767,31 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction } it := data.Items[idx] - mi, ok := magicItemFromAdvItem(it) - if !ok || mi.Slot == "" { + out, err := applyMagicEquip(ctx.Sender, it) + if errors.Is(err, errItemNotEquippable) { return p.SendDM(ctx.Sender, "That item can't be equipped anymore.") } - - equipped, err := loadEquippedMagicItems(ctx.Sender) if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.") - } - - // Return whatever currently occupies that slot to inventory at full - // value — swapping a curio shouldn't tax it. Evict from the local map - // too, so the attunement count below reflects the post-swap state and - // can re-open a slot the prior occupant was holding. - var swappedBackName string - if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" { - back := magicItemSellAt(prev.Item, prev.Temper) - back.SkillSource = "magic_item:" + prev.Item.ID - if err := addAdvInventoryItem(ctx.Sender, back); err != nil { - return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.") - } - swappedBackName = prev.Item.Name - delete(equipped, mi.Slot) - } - - // Auto-attune when the item needs it and an attunement slot is free. - // Otherwise it equips inert until the player frees a slot. - attune := false - atCap := false - if mi.Attunement { - if countAttunedMagicItems(equipped) >= dndMagicItemAttuneLimit { - atCap = true - } else { - attune = true - } - } - // Remove the inventory row FIRST, then equip. If equip fails after the - // remove succeeded, restore inventory. Doing it in the other order - // meant a transient DB error on remove left the item both equipped - // *and* still in inventory — a free duplication. - if err := removeAdvInventoryItem(it.ID); err != nil { - slog.Error("magic-item: failed to remove from inventory before equip", - "user", ctx.Sender, "item", mi.ID, "err", err) - return p.SendDM(ctx.Sender, "Failed to equip that item.") - } - if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune, it.Temper); err != nil { - // Roll back: try to put the item back in inventory so the player - // doesn't lose it. Best-effort; log if the rollback also fails. - restored := magicItemSellAt(mi, it.Temper) - restored.Value = it.Value - restored.SkillSource = "magic_item:" + mi.ID - if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil { - slog.Error("magic-item: equip failed AND inventory rollback failed", - "user", ctx.Sender, "item", mi.ID, "equip_err", err, "rollback_err", rbErr) - } return p.SendDM(ctx.Sender, "Failed to equip that item.") } - eqMI := temperedItem(mi, it.Temper) var sb strings.Builder sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.", - eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI))) - if swappedBackName != "" { - sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName)) + out.Effective.Name, out.Effective.Slot, magicItemEffectSummary(out.Effective))) + if out.SwappedBack != "" { + sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", out.SwappedBack)) } switch { - case mi.Attunement && attune: + case out.Bonded: sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).", - countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit)) - case mi.Attunement && atCap: + out.BondsBefore+1, dndMagicItemAttuneLimit)) + case out.AtCap: sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure unequip-magic` to take a bonded item off; this one bonds automatically once a slot opens).", dndMagicItemAttuneLimit)) } - - // Swapping out the prior occupant may have freed a bond slot — light up any - // item that was stranded inert (including a previously-equipped one the - // picker could never reach). - if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 { + if len(out.Healed) > 0 { sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.", - strings.Join(healed, "**, **"))) + strings.Join(out.Healed, "**, **"))) } return p.SendDM(ctx.Sender, sb.String()) } @@ -783,39 +856,19 @@ func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interacti } slot := data.Slots[idx] - equipped, err := loadEquippedMagicItems(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.") - } - e, ok := equipped[slot] - if !ok || e.Item.ID == "" { + out, err := applyMagicUnequip(ctx.Sender, slot) + if errors.Is(err, errSlotEmpty) { return p.SendDM(ctx.Sender, "That slot is already empty.") } - - // Clear the slot FIRST, then return the item to inventory at full value. - // This mirrors the equip resolver's ordering (destructive op first, restore - // on failure): the other order could leave the item both worn and in - // inventory — a free duplicate — on a transient DB error. - if err := unequipMagicItem(ctx.Sender, slot); err != nil { + if err != nil { return p.SendDM(ctx.Sender, "Failed to take that item off.") } - back := magicItemSellAt(e.Item, e.Temper) - back.SkillSource = "magic_item:" + e.Item.ID - if err := addAdvInventoryItem(ctx.Sender, back); err != nil { - // Roll back: re-equip exactly as it was so the item isn't lost. - if rbErr := equipMagicItem(ctx.Sender, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil { - slog.Error("magic-item: unequip failed AND re-equip rollback failed", - "user", ctx.Sender, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr) - } - return p.SendDM(ctx.Sender, "Failed to return that item to your inventory.") - } - mi := e.Effective() - msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", mi.Name, slot) + msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", out.Item.Name, slot) // Freeing a bonded slot may let a worn-but-inert item finally bond. - if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 { + if len(out.Healed) > 0 { msg += fmt.Sprintf("\n🔗 That freed a bond slot — **%s** is now active.", - strings.Join(healed, "**, **")) + strings.Join(out.Healed, "**, **")) } return p.SendDM(ctx.Sender, msg) } diff --git a/internal/plugin/pete_equip.go b/internal/plugin/pete_equip.go new file mode 100644 index 0000000..76cb024 --- /dev/null +++ b/internal/plugin/pete_equip.go @@ -0,0 +1,240 @@ +package plugin + +// The web equip queue's game-side loop. +// +// An owner asks, on their own detail page on Pete, to wear or take off an item. +// Pete records the intent; we poll for it, run the real equip against our own +// equipment tables, and file a verdict Pete shows them. Same reverse-pipe shape +// as mischief — Pete has no route into this box, so we ask for work rather than +// being told about it. +// +// The one thing that is NOT like mischief: the underlying action isn't +// idempotent. Equipping consumes an inventory row and unequipping mints a fresh +// one, so simply re-running a re-offered order would double-move the item. So +// before we touch anything we check the equip_applied_orders ledger: if this +// order's guid is already there, the mutation happened on an earlier tick and we +// only lost the verdict-ack — we re-file the stored verdict and mutate nothing. +// The guid is still the end-to-end key; here it guards a non-idempotent action +// instead of riding a naturally idempotent one. + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +const ( + equipPollInterval = 30 * time.Second + equipPollTimeout = 20 * time.Second +) + +// peteEquipTicker polls Pete for equip orders and fulfils them. Started alongside +// the other adventure tickers; exits on stopCh. +func (p *AdventurePlugin) peteEquipTicker() { + if !peteclient.Enabled() { + return // no Pete wire configured; the equip queue is simply off + } + ticker := time.NewTicker(equipPollInterval) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.pollEquipOrders() + } + } +} + +func (p *AdventurePlugin) pollEquipOrders() { + ctx, cancel := context.WithTimeout(context.Background(), equipPollTimeout) + defer cancel() + + orders, err := peteclient.PendingEquip(ctx) + if err != nil { + // A Pete predating the queue answers 404; a wire blip looks the same. Quiet + // on purpose — this must not spam while Pete hasn't shipped the endpoint. + slog.Debug("equip: poll failed", "err", err) + return + } + for _, order := range orders { + p.fulfilEquipOrder(ctx, order) + } +} + +// fulfilEquipOrder applies one order and files its verdict. A transient failure is +// left pending for the next poll (no verdict); a permanent one gets a specific +// rejection. The guid ledger makes a re-offer after a lost ack a no-op that simply +// re-files the verdict. +func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient.EquipOrder) { + // Already applied on an earlier tick? Re-file the stored verdict, mutate nothing. + if status, detail, ok := equipOrderAlreadyApplied(order.GUID); ok { + if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil { + slog.Warn("equip: re-file verdict push failed, will retry next poll", + "order", order.GUID, "status", status, "err", err) + } + return + } + + owner, ok := p.equipOwnerMXID(order.OwnerLocalpart) + if !ok { + // The client isn't up (tests) or the localpart is empty. Not our order to + // fail permanently — leave it pending and try again once we can name the owner. + slog.Debug("equip: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart) + return + } + + status, detail, retry := p.applyEquipOrder(owner, order) + if retry { + return // transient; leave pending for the next tick + } + + // Record the verdict BEFORE pushing it, so a crash after the mutation still + // short-circuits next tick and re-files rather than re-applying. The mutation + // and this insert aren't one transaction, but the window between them is a + // single statement — the same practical guarantee the DM equip path lives with. + if err := recordEquipApplied(order.GUID, status, detail); err != nil { + // If we can't record it, don't push the verdict either: leave the order + // pending so the ledger and Pete stay in step. Re-running an equip is the + // double-move we're guarding against, so a rare re-apply here is the lesser + // evil versus a verdict with no ledger behind it. Transient; retry. + slog.Warn("equip: failed to record applied order, leaving pending", + "order", order.GUID, "status", status, "err", err) + return + } + if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil { + slog.Warn("equip: verdict push failed, will re-file next poll", + "order", order.GUID, "status", status, "err", err) + return + } + slog.Info("equip: web order fulfilled", "order", order.GUID, "action", order.Action, "status", status) +} + +// applyEquipOrder runs the real equip/unequip. It returns the terminal status and +// a human 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. +func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) { + switch order.Action { + case "equip": + inv, err := loadAdvInventory(owner) + if err != nil { + return "", "", true // transient + } + var it AdvItem + found := false + for _, cand := range inv { + if cand.ID == order.ItemID { + it, found = cand, true + break + } + } + if !found { + // The row left the pack before we got here (worn already, sold, a stale + // page). The table is AUTOINCREMENT, so the id can't have been reused for + // a different item — this is a clean miss, not a wrong hit. + return "rejected_not_owned", "That item wasn't in your pack anymore.", false + } + out, err := applyMagicEquip(owner, it) + if errors.Is(err, errItemNotEquippable) { + return "rejected_not_equippable", "That item can't be worn.", false + } + if err != nil { + return "", "", true // transient DB fault + } + return "applied", equipAppliedDetail(out), false + + case "unequip": + out, err := applyMagicUnequip(owner, DnDSlot(order.Slot)) + if errors.Is(err, errSlotEmpty) { + return "rejected_not_worn", "That slot was already empty.", false + } + if err != nil { + return "", "", true + } + note := fmt.Sprintf("Took off %s, back in your pack.", out.Item.Name) + if len(out.Healed) > 0 { + note += fmt.Sprintf(" That freed a bond, so %s is active now.", strings.Join(out.Healed, ", ")) + } + return "applied", note, false + + 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. + return "rejected_not_equippable", "Unknown action.", false + } +} + +// equipAppliedDetail turns an equip outcome into the plain note Pete shows. +func equipAppliedDetail(out magicEquipOutcome) string { + b := fmt.Sprintf("Now worn in your %s slot.", out.Effective.Slot) + switch { + case out.Bonded: + b += fmt.Sprintf(" Bonded (%d of %d).", out.BondsBefore+1, dndMagicItemAttuneLimit) + case out.AtCap: + b += fmt.Sprintf(" Worn but inert: all %d bonds are in use, so take one off to activate it.", dndMagicItemAttuneLimit) + } + if out.SwappedBack != "" { + b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack) + } + if len(out.Healed) > 0 { + b += fmt.Sprintf(" A freed bond also activated %s.", strings.Join(out.Healed, ", ")) + } + return b +} + +// equipOwnerMXID reconstructs the owner's Matrix id from the localpart Pete sent, +// the same construction as the mischief buyer's. Fails closed if the client isn't +// up (tests) or the name is empty. +func (p *AdventurePlugin) equipOwnerMXID(localpart string) (id.UserID, bool) { + lp := strings.ToLower(strings.TrimSpace(localpart)) + if lp == "" || p.Client == nil { + return "", false + } + server := p.Client.UserID.Homeserver() + if server == "" { + return "", false + } + return id.NewUserID(lp, server), true +} + +// ---- the applied-order ledger -------------------------------------------------- + +// equipOrderAlreadyApplied reports the verdict we filed for an order, if we have +// already applied it. This is the short-circuit that keeps a re-offered order from +// re-running its non-idempotent mutation. +func equipOrderAlreadyApplied(guid string) (status, detail string, ok bool) { + err := db.Get().QueryRow( + `SELECT status, detail FROM equip_applied_orders WHERE guid = ?`, guid, + ).Scan(&status, &detail) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false + } + if err != nil { + // A read failure here would send us down the mutation path and risk a + // double-move, so treat it as "don't know" and let the caller leave the + // order pending rather than assume it's fresh. We signal that by returning + // ok=false but... the caller can't tell the difference. Log loudly; a + // persistent read failure is a real problem, but a transient one self-heals + // on the next poll because the mutation itself is guarded by this same table. + slog.Error("equip: applied-ledger read failed", "order", guid, "err", err) + return "", "", false + } + return status, detail, true +} + +// recordEquipApplied stamps an order as applied with the verdict we're about to +// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid. +func recordEquipApplied(guid, status, detail string) error { + _, err := db.Get().Exec( + `INSERT OR IGNORE INTO equip_applied_orders (guid, status, detail) VALUES (?, ?, ?)`, + guid, status, detail) + return err +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index c37d5e8..be75e01 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -242,6 +242,14 @@ func itemViews(items []AdvItem) []peteclient.ItemView { v.Desc = eff.Desc v.Effect = magicItemEffectSummary(eff) v.Attunement = eff.Attunement + // The row id is the equip handle: a slotted magic item is the one thing + // the web equip path can wear, so only it carries an id. Mundane gear (the + // branch below) and unslotted curios get none, so no Equip button. This + // runs for vault rows too — a vault magic item would carry an id — but Pete + // offers the button on the backpack panel alone, so that's inert, not a leak. + if eff.Slot != "" { + v.ID = it.ID + } } else if it.Slot != "" { // Shop equipment resolves by (slot, tier) — Name is decorative. v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description