mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
A high-effort code review of the gifting/vault work turned up three issues: 1. Item duplication (real exploit). The masterwork/arena equip confirmation captures an item at prompt time and equips it on "yes" without re-checking ownership. Since MasterworkGear/ArenaGear are now giftable, a player could !give the item away in the window, then confirm — minting a copy onto themselves while the recipient kept theirs. Fixed by taking the user lock (making gift-vs-confirm atomic) and re-loading the inventory to refuse an item that is no longer ours. Magic items are exempt (not giftable); other delete paths load-and-remove within one locked invocation. 2. clearAdvInventory read the vault-filtered list but its DELETE wiped every row including vaulted ones — no live caller today, but it would break the vault's "safe from !sell all" promise the instant sell-all routed through it. Delete now matches the read (in_vault = 0). 3. vault store/take took no per-user lock, so two near-simultaneous stores could both pass the capacity check and overfill the 10-slot vault. Now serialized on the same user lock the gift path uses. go test ./... green; golden byte-identical. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
134 lines
5.1 KiB
Go
134 lines
5.1 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N4/E1 — the Tier-4 "Established" home unlocks a vault: a fixed pool of slots
|
|
// that shelters items from `!sell all`, crafting, and combat consumption. It is
|
|
// pure storage — a vaulted item drops out of every play surface (loadAdvInventory
|
|
// filters it) until it is taken back — and the plumbing E2 gifting builds on.
|
|
const (
|
|
vaultCapacity = 10 // slots an Established home provides
|
|
vaultMinHouseTier = 4 // Tier 4 "Established" gates the vault
|
|
)
|
|
|
|
// vaultUnlocked reports whether the character's house tier grants a vault, and a
|
|
// player-facing refusal line when it does not.
|
|
func vaultUnlocked(char *AdventureCharacter) (bool, string) {
|
|
if char.HouseTier >= vaultMinHouseTier {
|
|
return true, ""
|
|
}
|
|
return false, "🔒 A vault comes with an **Established** home (Tier 4). Upgrade your house at `!thom` to unlock " +
|
|
fmt.Sprintf("%d slots of protected storage — vaulted items are safe from `!sell all` and never spent in combat.", vaultCapacity)
|
|
}
|
|
|
|
// handleVaultCmd routes `!adventure vault [store|take] <item>`. The reply-building
|
|
// core is client-free (renderVault / vaultStoreItem / vaultTakeItem) so it is
|
|
// unit-testable without a Matrix stub; the handler only loads the character and
|
|
// sends the result.
|
|
func (p *AdventurePlugin) handleVaultCmd(ctx MessageContext, args string) error {
|
|
char, _, err := p.ensureCharacter(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
|
}
|
|
if !char.Alive {
|
|
return p.SendDM(ctx.Sender, "You're dead. The vault stays locked.")
|
|
}
|
|
if ok, refusal := vaultUnlocked(char); !ok {
|
|
return p.SendDM(ctx.Sender, refusal)
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(args))
|
|
var reply string
|
|
switch {
|
|
case lower == "" || lower == "list":
|
|
reply = renderVault(ctx.Sender)
|
|
case strings.HasPrefix(lower, "store "):
|
|
// Serialize a player's own store/take so two near-simultaneous stores
|
|
// can't both pass the capacity check and overfill the vault.
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
reply = vaultStoreItem(ctx.Sender, strings.TrimSpace(args[len("store "):]))
|
|
userMu.Unlock()
|
|
case strings.HasPrefix(lower, "take "):
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
reply = vaultTakeItem(ctx.Sender, strings.TrimSpace(args[len("take "):]))
|
|
userMu.Unlock()
|
|
default:
|
|
reply = "Usage: `!adventure vault` to view · `!adventure vault store <item>` · `!adventure vault take <item>`."
|
|
}
|
|
return p.SendDM(ctx.Sender, reply)
|
|
}
|
|
|
|
// renderVault shows what is stowed and how many slots remain.
|
|
func renderVault(userID id.UserID) string {
|
|
items, err := loadAdvVault(userID)
|
|
if err != nil {
|
|
return "The vault door won't budge. Try again in a moment."
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("🏛️ **Your Vault** — %d / %d slots\n\n", len(items), vaultCapacity))
|
|
if len(items) == 0 {
|
|
sb.WriteString("Empty. `!adventure vault store <item>` to shelter something from sale or use.")
|
|
return sb.String()
|
|
}
|
|
for _, it := range items {
|
|
name := it.Name
|
|
if it.Temper > 0 {
|
|
name = fmt.Sprintf("%s +%d", name, it.Temper)
|
|
}
|
|
sb.WriteString(fmt.Sprintf("• %s — €%d\n", name, it.Value))
|
|
}
|
|
sb.WriteString("\n`!adventure vault take <item>` to bring one back into play.")
|
|
return sb.String()
|
|
}
|
|
|
|
// vaultStoreItem moves one matching inventory item into the vault. Guards the
|
|
// capacity and reports the fresh slot count so the player never has to re-list.
|
|
func vaultStoreItem(userID id.UserID, query string) string {
|
|
if query == "" {
|
|
return "Store what? `!adventure vault store <item>`."
|
|
}
|
|
if advVaultCount(userID) >= vaultCapacity {
|
|
return fmt.Sprintf("The vault is full (%d / %d). Take something out first with `!adventure vault take <item>`.", vaultCapacity, vaultCapacity)
|
|
}
|
|
items, err := loadAdvInventory(userID)
|
|
if err != nil {
|
|
return "Couldn't reach your inventory. Try again in a moment."
|
|
}
|
|
match := findInventoryMatch(items, query)
|
|
if match == nil {
|
|
return fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", query)
|
|
}
|
|
moved, err := setItemVaulted(userID, match.ID, true)
|
|
if err != nil || !moved {
|
|
return "Couldn't stow that item. Try again in a moment."
|
|
}
|
|
return fmt.Sprintf("🏛️ Stowed **%s** in the vault. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
|
|
}
|
|
|
|
// vaultTakeItem returns one matching vaulted item to the active inventory.
|
|
func vaultTakeItem(userID id.UserID, query string) string {
|
|
if query == "" {
|
|
return "Take what? `!adventure vault take <item>`."
|
|
}
|
|
items, err := loadAdvVault(userID)
|
|
if err != nil {
|
|
return "The vault door won't budge. Try again in a moment."
|
|
}
|
|
match := findInventoryMatch(items, query)
|
|
if match == nil {
|
|
return fmt.Sprintf("Nothing in the vault matches %q. Check `!adventure vault`.", query)
|
|
}
|
|
moved, err := setItemVaulted(userID, match.ID, false)
|
|
if err != nil || !moved {
|
|
return "Couldn't retrieve that item. Try again in a moment."
|
|
}
|
|
return fmt.Sprintf("Took **%s** out of the vault and back into your pack. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
|
|
}
|