mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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.
241 lines
9.1 KiB
Go
241 lines
9.1 KiB
Go
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
|
|
}
|