package plugin // Ask 7: full equipment management from the web. // // The magic-item equip path (magic_items_gameplay.go) only ever touched the DnD // slots — off_hand, rings, and the like — which are almost always empty. Almost // everything a player actually wears lives in the OTHER two systems: the 5 // standard EquipmentSlots (weapon/armor/helmet/boots/tool), whose power is the // slot's integer Tier, and the masterwork/arena pieces that get equipped INTO a // standard slot. This file is the game-side of managing all of that from Pete: // // - applyMasterworkEquip / applyMasterworkUnequip — move a masterwork/arena // piece between the pack and a standard slot (no money). // - purchaseEquipmentTier — buy the next standard tier with euros (confirm-gated // on the web), the headless twin of the shop's advBuyEquipment. // - repairSlot — mend a slot's condition with euros, the headless twin of the // blacksmith's executeRepair. // - buildEquipSlotViews — the owner-only snapshot the web panel renders from. // // The two euro-spending mutators run on the retrying poll wire, so every money // move goes through the idempotent euro variants keyed on the order guid: a // re-offered order that already debited skips the charge and just re-runs the // idempotent slot write. That is why neither refunds on a later DB fault — a // refund keyed on a fresh id, followed by a guid-guarded retry that no longer // re-debits, would hand the player both the gear and their money back. The casino // escrow (pete_games.go) settles the same way: idempotent move, then retry. import ( "errors" "fmt" "log/slog" "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) // errEquipDowngrade is a permanent refusal: the incoming piece is no better than // what is worn. Downgrades are blocked by user decision (equip and upgrade both). var errEquipDowngrade = errors.New("equip: would be a downgrade") // mwEquipOutcome is what a masterwork/arena equip did, for the verdict note. type mwEquipOutcome struct { Name string Slot EquipmentSlot Tier int Arena bool SwappedBack string // the special occupant evicted back to the pack, or "" } // applyMasterworkEquip wears one masterwork/arena backpack piece into its standard // slot. Ordering is anti-duplication AND safe under the equip poll's 30s retry: // remove the incoming row FIRST (restoring it on a save fault), then write the // slot, and only THEN evict any displaced special occupant back to the pack. The // eviction comes last, once the slot no longer references the occupant, so it can // never mint a duplicate; and it is best-effort — a failure there is logged, not // aborted on, the same tolerance the DM confirm handler (adventure_masterwork.go) // lives with. Aborting after the slot write would strand a completed equip for a // retry that re-evicts the occupant on every tick. func applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error) { if it.Slot == "" || (it.Type != "MasterworkGear" && it.Type != "ArenaGear") { return mwEquipOutcome{}, errItemNotEquippable } equip, err := loadAdvEquipment(uid) if err != nil { return mwEquipOutcome{}, err } slot := it.Slot cur := equip[slot] // Downgrade block: the incoming effective tier must beat the current occupant. incoming := &AdvEquipment{Tier: it.Tier} if it.Type == "ArenaGear" { incoming.ArenaTier = it.Tier } else { incoming.Masterwork = true } if advEffectiveTier(incoming) <= advEffectiveTier(cur) { return mwEquipOutcome{}, errEquipDowngrade } // Capture the special occupant to evict, if any, BEFORE the slot write below // mutates cur in place. A plain shop-tier occupant is not an item — it is just // the slot's tier — so it is overwritten, not evicted, the same as the DM confirm // handler and the shop. The actual re-pack happens after the slot write (below), // so it can never duplicate the piece. var evicted *AdvItem if cur != nil && (cur.Masterwork || cur.ArenaTier > 0) { old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource} if cur.ArenaTier > 0 { old.Type = "ArenaGear" } evicted = &old } // Destructive op first: pull the incoming row before writing the slot, so a save // fault can't leave it both worn and in the pack. Restore it on failure. if err := removeAdvInventoryItem(it.ID); err != nil { return mwEquipOutcome{}, err } eq := cur if eq == nil { eq = &AdvEquipment{Slot: slot} } eq.Tier = it.Tier eq.Condition = 100 eq.Name = it.Name eq.ActionsUsed = 0 if it.Type == "ArenaGear" { eq.Masterwork = false eq.SkillSource = "" eq.ArenaTier = it.Tier eq.ArenaSet = "" if gs := arenaGearByName(it.Name); gs != nil { eq.ArenaSet = gs.SetKey } } else { eq.ArenaTier = 0 eq.ArenaSet = "" eq.Masterwork = true eq.SkillSource = it.SkillSource } if err := saveAdvEquipment(uid, eq); err != nil { restored := AdvItem{Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Slot: it.Slot, SkillSource: it.SkillSource} if rbErr := addAdvInventoryItem(uid, restored); rbErr != nil { slog.Error("equip: masterwork save failed AND inventory rollback failed", "user", uid, "item", it.Name, "save_err", err, "rollback_err", rbErr) } return mwEquipOutcome{}, err } // The slot now holds the incoming piece, so the former occupant is referenced // nowhere — re-packing it now cannot duplicate it. Best-effort: a failure is a // bounded, non-compounding loss we log rather than abort on, since the equip has // already succeeded and aborting would re-run (and re-evict) on the next poll. var swappedBack string if evicted != nil { if err := addAdvInventoryItem(uid, *evicted); err != nil { slog.Error("equip: masterwork equipped but evicted piece failed to return to pack", "user", uid, "evicted", evicted.Name, "err", err) } else { swappedBack = evicted.Name } } return mwEquipOutcome{Name: it.Name, Slot: slot, Tier: it.Tier, Arena: it.Type == "ArenaGear", SwappedBack: swappedBack}, nil } // mwUnequipOutcome is what a masterwork/arena take-off did. type mwUnequipOutcome struct { Name string Slot EquipmentSlot } // applyMasterworkUnequip takes a worn masterwork/arena piece off a standard slot, // returns it to the pack, and resets the slot to its tier-0 default. A plain // shop-tier slot has nothing round-trippable (its tier is not an item), so that is // errSlotEmpty — reverting a shop tier is not a take-off. The 5 slot rows are an // invariant (PK user_id+slot), so the row is reset, never deleted. Destructive op // first — reset the slot, then mint the pack row, restoring the slot on failure — // mirroring the magic unequip so a fault can't duplicate the piece. func applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error) { equip, err := loadAdvEquipment(uid) if err != nil { return mwUnequipOutcome{}, err } cur := equip[slot] if cur == nil || (!cur.Masterwork && cur.ArenaTier == 0) { return mwUnequipOutcome{}, errSlotEmpty } prev := *cur // snapshot for rollback def0 := equipmentTiers[slot][0] reset := &AdvEquipment{Slot: slot, Tier: 0, Condition: 100, Name: def0.Name, ActionsUsed: 0, ArenaTier: 0, ArenaSet: "", Masterwork: false, SkillSource: ""} if err := saveAdvEquipment(uid, reset); err != nil { return mwUnequipOutcome{}, err } old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource} if cur.ArenaTier > 0 { old.Type = "ArenaGear" } if err := addAdvInventoryItem(uid, old); err != nil { if rbErr := saveAdvEquipment(uid, &prev); rbErr != nil { slog.Error("equip: masterwork take-off failed AND slot rollback failed", "user", uid, "slot", slot, "add_err", err, "rollback_err", rbErr) } return mwUnequipOutcome{}, err } return mwUnequipOutcome{Name: cur.Name, Slot: slot}, nil } // purchaseEquipmentTier buys a standard slot's tier with euros — the headless twin // of advBuyEquipment, minus flavor. It returns a terminal verdict for Pete or // retry=true for a transient fault. Money moves once, keyed on the order guid; the // web only ever offers the next tier over a PLAIN shop-tier slot (buildEquipSlotViews // suppresses the offer on special gear), so there is no occupant to evict here and // the whole body is idempotent under a re-offered order. func (p *AdventurePlugin) purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (status, detail string, retry bool) { defs, ok := equipmentTiers[slot] if !ok { return "rejected_not_equippable", "That isn't an equipment slot.", false } if tier < 1 || tier >= len(defs) { // tier 0 is the free default, not a purchase; >= len is past the top tier. return "rejected_max_tier", "That slot is already at the top tier.", false } def := defs[tier] equip, err := loadAdvEquipment(uid) if err != nil { return "", "", true } cur := equip[slot] if cur != nil { // Buying a shop tier over a special piece strips its bonus — a downgrade in // practice even when the raw number rises. Take it off first, then buy. if cur.Masterwork || cur.ArenaTier > 0 { return "rejected_downgrade", "Take off your special gear in that slot before buying a tier.", false } if cur.Tier >= def.Tier { return "rejected_downgrade", "You already have that tier or better.", false } } price := def.Price if !p.euro.HasExternalTx(guid) { ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", guid) if err != nil { return "", "", true } if !ok { return "rejected_insufficient_funds", fmt.Sprintf("That upgrade costs €%.0f and you can't cover it.", price), false } } eq := &AdvEquipment{Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0} if err := saveAdvEquipment(uid, eq); err != nil { // No refund: the debit is guid-idempotent, so the next poll re-runs this with // the charge already settled and only the (idempotent) slot write left to do. // Refunding here would double-pay once that retry lands the gear. return "", "", true } return "applied", fmt.Sprintf("Upgraded your %s to %s (T%d) for €%.0f.", slot, def.Name, def.Tier, price), false } // repairSlot mends one standard slot's condition with euros — the headless twin of // the blacksmith's executeRepair. Idempotent on the order guid: the debit runs // once, and setting condition to 100 is itself idempotent, so a re-offered order is // safe with no refund. func (p *AdventurePlugin) repairSlot(uid id.UserID, slot EquipmentSlot, guid string) (status, detail string, retry bool) { equip, err := loadAdvEquipment(uid) if err != nil { return "", "", true } eq := equip[slot] if eq == nil { return "rejected_not_worn", "There's nothing in that slot to repair.", false } cost := blacksmithRepairCost(eq) if cost <= 0 { // Already full — nothing to charge for. Report it as applied so the order // reaches a terminal state rather than parking. return "applied", "That piece was already at full condition.", false } if !p.euro.HasExternalTx(guid) { ok, _, err := p.euro.DebitIdem(uid, float64(cost), "adventure_repair", guid) if err != nil { return "", "", true } if !ok { return "rejected_insufficient_funds", fmt.Sprintf("The repair costs €%d and you can't cover it.", cost), false } } eq.Condition = 100 if err := saveAdvEquipment(uid, eq); err != nil { return "", "", true // retry; the idempotent debit means no double-charge } return "applied", fmt.Sprintf("Repaired your %s for €%d.", eq.Name, cost), false } // buildEquipSlotViews is the owner-only snapshot of the 5 standard slots the web // management panel renders from. Worn masterwork/arena pieces surface here (via // CanTakeOff), not in the magic Equipped set. An upgrade is offered only over a // plain shop-tier slot below max — a special piece is taken off, not shop-upgraded. func buildEquipSlotViews(uid id.UserID) []peteclient.EquipSlotView { equip, err := loadAdvEquipment(uid) if err != nil { return nil } var out []peteclient.EquipSlotView for _, slot := range allSlots { eq := equip[slot] if eq == nil { continue } v := peteclient.EquipSlotView{ Slot: string(slot), Name: eq.Name, Tier: eq.Tier, Condition: eq.Condition, Masterwork: eq.Masterwork, ArenaTier: eq.ArenaTier, CanTakeOff: eq.Masterwork || eq.ArenaTier > 0, RepairCost: blacksmithRepairCost(eq), } if !eq.Masterwork && eq.ArenaTier == 0 && eq.Tier < 5 { next := equipmentTiers[slot][eq.Tier+1] v.NextTier = next.Tier v.NextName = next.Name v.NextPrice = next.Price } out = append(out, v) } return out } // masterworkEquipDetail turns a masterwork/arena equip outcome into the verdict // note Pete shows. func masterworkEquipDetail(out mwEquipOutcome) string { kind := "masterwork" if out.Arena { kind = "arena" } b := fmt.Sprintf("Now worn in your %s slot (%s T%d).", out.Slot, kind, out.Tier) if out.SwappedBack != "" { b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack) } return b } // isEquipmentSlot reports whether a slot string names one of the 5 standard slots. // The magic DnD slots and the standard slots are disjoint vocabularies, so this // alone routes an unequip to the right path. func isEquipmentSlot(slot string) bool { for _, s := range allSlots { if string(s) == slot { return true } } return false }