mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
adventure: drain Pete's equip queue and apply it for real
Poll Pete for equip/unequip orders an owner placed on the web, run them through the real magic-item equip path so bond caps and slot eviction still hold, and file a verdict. Same reverse pipe as mischief, with one difference that matters: the equip action is not idempotent, so a re-offered order after a lost ack would double-move the item. An equip_applied_orders ledger keyed on the order guid is the guard: applied once, re-offers only re-file the stored verdict. The delicate remove-before-equip ordering that prevents item duplication is now one shared applyMagicEquip/applyMagicUnequip core, called by both the DM resolver and this poller, so the anti-dup ordering can't drift between two copies. Items now carry their inventory row id to Pete as the equip handle.
This commit is contained in:
@@ -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_buyer ON mischief_contracts(buyer_id, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_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
|
-- Babysitting Service
|
||||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ type PlayerDetail struct {
|
|||||||
// with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding
|
// 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.
|
// what to wear needs to see the difference.
|
||||||
type ItemView struct {
|
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"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Tier int `json:"tier"`
|
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)
|
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.
|
// getJSON does a bearer-authed GET and decodes the body.
|
||||||
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.expeditionBoredomTicker()
|
go p.expeditionBoredomTicker()
|
||||||
go p.mischiefTicker()
|
go p.mischiefTicker()
|
||||||
go p.peteMischiefTicker()
|
go p.peteMischiefTicker()
|
||||||
|
go p.peteEquipTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
p.arenaCleanupStaleRuns()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -559,6 +560,133 @@ func magicItemEffectSummary(mi MagicItem) string {
|
|||||||
return strings.Join(parts, ", ")
|
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 {
|
func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
|
||||||
// Self-heal first: bond any worn item stranded inert while a slot is free
|
// 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
|
// (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]
|
it := data.Items[idx]
|
||||||
mi, ok := magicItemFromAdvItem(it)
|
out, err := applyMagicEquip(ctx.Sender, it)
|
||||||
if !ok || mi.Slot == "" {
|
if errors.Is(err, errItemNotEquippable) {
|
||||||
return p.SendDM(ctx.Sender, "That item can't be equipped anymore.")
|
return p.SendDM(ctx.Sender, "That item can't be equipped anymore.")
|
||||||
}
|
}
|
||||||
|
|
||||||
equipped, err := loadEquippedMagicItems(ctx.Sender)
|
|
||||||
if err != nil {
|
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.")
|
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||||
}
|
}
|
||||||
|
|
||||||
eqMI := temperedItem(mi, it.Temper)
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
|
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
|
||||||
eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI)))
|
out.Effective.Name, out.Effective.Slot, magicItemEffectSummary(out.Effective)))
|
||||||
if swappedBackName != "" {
|
if out.SwappedBack != "" {
|
||||||
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName))
|
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", out.SwappedBack))
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case mi.Attunement && attune:
|
case out.Bonded:
|
||||||
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
||||||
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
|
out.BondsBefore+1, dndMagicItemAttuneLimit))
|
||||||
case mi.Attunement && atCap:
|
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).",
|
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))
|
dndMagicItemAttuneLimit))
|
||||||
}
|
}
|
||||||
|
if len(out.Healed) > 0 {
|
||||||
// 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 {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.",
|
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())
|
return p.SendDM(ctx.Sender, sb.String())
|
||||||
}
|
}
|
||||||
@@ -783,39 +856,19 @@ func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interacti
|
|||||||
}
|
}
|
||||||
|
|
||||||
slot := data.Slots[idx]
|
slot := data.Slots[idx]
|
||||||
equipped, err := loadEquippedMagicItems(ctx.Sender)
|
out, err := applyMagicUnequip(ctx.Sender, slot)
|
||||||
if err != nil {
|
if errors.Is(err, errSlotEmpty) {
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
|
|
||||||
}
|
|
||||||
e, ok := equipped[slot]
|
|
||||||
if !ok || e.Item.ID == "" {
|
|
||||||
return p.SendDM(ctx.Sender, "That slot is already empty.")
|
return p.SendDM(ctx.Sender, "That slot is already empty.")
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
// 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 {
|
|
||||||
return p.SendDM(ctx.Sender, "Failed to take that item off.")
|
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.", out.Item.Name, slot)
|
||||||
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", mi.Name, slot)
|
|
||||||
// Freeing a bonded slot may let a worn-but-inert item finally bond.
|
// 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.",
|
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)
|
return p.SendDM(ctx.Sender, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
240
internal/plugin/pete_equip.go
Normal file
240
internal/plugin/pete_equip.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -242,6 +242,14 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
|
|||||||
v.Desc = eff.Desc
|
v.Desc = eff.Desc
|
||||||
v.Effect = magicItemEffectSummary(eff)
|
v.Effect = magicItemEffectSummary(eff)
|
||||||
v.Attunement = eff.Attunement
|
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 != "" {
|
} else if it.Slot != "" {
|
||||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
||||||
|
|||||||
Reference in New Issue
Block a user