mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N4/E2: item gifting — !give <item> @user
Hands a consumable or non-magic gear item to another adventurer. The sender pays a 5% handling fee (of item value) to the community pot — the same civic tax every payout pays — and is capped at 3 gifts/day (a persisted log doubling as an anti-twink-funnel guard and audit trail). Recipient must have run !setup. Magic items are deliberately non-giftable: attunement and the BiS economy stay personal. Built on the vault's inventory plumbing: a gift is transferInventoryItem flipping the row's owner (owner-scoped, forces in_vault=0 so it lands in the recipient's active pack). pickGiftableItem prefers a giftable match so a non-giftable item can't shadow a giftable one and block the command. go test ./... green; golden byte-identical. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -1383,6 +1383,21 @@ CREATE TABLE IF NOT EXISTS adventure_activity_log (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
|
||||
|
||||
-- N4/E2 item gifting. One row per gift; the sender's row count for the current
|
||||
-- UTC day enforces the daily cap (twink-funnel guard), and the full log is an
|
||||
-- audit trail. gift_day is the UTC date string the cap counts against, kept
|
||||
-- alongside given_at so the count is a plain equality match, not a range scan.
|
||||
CREATE TABLE IF NOT EXISTS adventure_gift_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
value INTEGER NOT NULL DEFAULT 0,
|
||||
gift_day TEXT NOT NULL,
|
||||
given_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_gift_sender_day ON adventure_gift_log(sender, gift_day);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adventure_treasures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -179,6 +179,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||||
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
|
||||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
|
||||
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +417,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "lore") {
|
||||
return p.handleLoreCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "give") {
|
||||
return p.handleGiveCmd(ctx)
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
|
||||
200
internal/plugin/adventure_gifting.go
Normal file
200
internal/plugin/adventure_gifting.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N4/E2 item gifting. `!give <item> @user` hands a consumable or non-magic gear
|
||||
// item to another adventurer. The sender pays a 5% handling fee (of item value)
|
||||
// to the community pot — the same civic tax every payout pays — and is capped at
|
||||
// a few gifts a day so the feature can't become a twink-funnel for a fresh alt.
|
||||
// Magic items are deliberately non-giftable: attunement and the BiS economy stay
|
||||
// personal (gogobee_engagement_plan.md §E2).
|
||||
const (
|
||||
giftDailyCap = 3
|
||||
giftTaxRate = 0.05
|
||||
)
|
||||
|
||||
// giftableTypes is the allowlist of AdvItem.Type values a player may gift.
|
||||
// Everything in adventure_inventory is unequipped by definition (equipped gear
|
||||
// lives in adventure_equipment / magic_item_equipped), so "unequipped" needs no
|
||||
// separate check. Magic items ("magic_item") and raw materials are excluded.
|
||||
var giftableTypes = map[string]bool{
|
||||
"consumable": true,
|
||||
"MasterworkGear": true,
|
||||
"ArenaGear": true,
|
||||
}
|
||||
|
||||
// isGiftableItem reports whether an inventory item may be gifted, and a reason
|
||||
// when it may not.
|
||||
func isGiftableItem(it AdvItem) (bool, string) {
|
||||
if it.Type == "magic_item" {
|
||||
return false, "Magic items stay personal — attunement doesn't transfer. Try a consumable or a piece of gear."
|
||||
}
|
||||
if !giftableTypes[it.Type] {
|
||||
return false, "You can only gift consumables and non-magic gear. Raw materials and treasures aren't giftable."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// pickGiftableItem resolves the item a `!give` query names, preferring a giftable
|
||||
// match so a non-giftable item (a magic item, raw materials) that also matches
|
||||
// can't shadow a giftable one and block the gift — the way plain findInventoryMatch
|
||||
// would, since it returns the first match across the whole inventory. Returns
|
||||
// (item, "") on success; (nil, "") when nothing matches at all; and (nil, reason)
|
||||
// when only non-giftable items match, so the caller can explain why.
|
||||
func pickGiftableItem(inv []AdvItem, query string) (*AdvItem, string) {
|
||||
match := findInventoryMatch(inv, query)
|
||||
if match == nil {
|
||||
return nil, ""
|
||||
}
|
||||
if ok, _ := isGiftableItem(*match); ok {
|
||||
return match, ""
|
||||
}
|
||||
giftables := make([]AdvItem, 0, len(inv))
|
||||
for _, it := range inv {
|
||||
if ok, _ := isGiftableItem(it); ok {
|
||||
giftables = append(giftables, it)
|
||||
}
|
||||
}
|
||||
if alt := findInventoryMatch(giftables, query); alt != nil {
|
||||
return alt, ""
|
||||
}
|
||||
_, reason := isGiftableItem(*match)
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
// giftCountToday returns how many gifts the sender has already sent on the given
|
||||
// UTC day. The cap is enforced against the persisted log, so a restart can't
|
||||
// reset someone's daily allowance.
|
||||
func giftCountToday(sender id.UserID, day string) int {
|
||||
var n int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_gift_log WHERE sender = ? AND gift_day = ?`,
|
||||
string(sender), day).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// logGift records a completed gift for the daily cap and the audit trail.
|
||||
func logGift(sender, recipient id.UserID, itemName string, value int64, day string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_gift_log (sender, recipient, item_name, value, gift_day)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
string(sender), string(recipient), itemName, value, day)
|
||||
return err
|
||||
}
|
||||
|
||||
// transferInventoryItem moves one inventory row from one owner to another. Scoped
|
||||
// to the current owner so a stale id can't move a row that has already changed
|
||||
// hands, and forces in_vault=0 so a gift always lands in the recipient's active
|
||||
// pack rather than a phantom vault slot.
|
||||
func transferInventoryItem(itemID int64, from, to id.UserID) (bool, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET user_id = ?, in_vault = 0 WHERE id = ? AND user_id = ?`,
|
||||
string(to), itemID, string(from))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// handleGiveCmd routes `!give <item> @user`.
|
||||
func (p *AdventurePlugin) handleGiveCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "give"))
|
||||
|
||||
// Serialize a sender's own gifts so two near-simultaneous !give commands
|
||||
// can't both clear the daily cap or double-spend the same item row.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
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. Your things stay where they are for now.")
|
||||
}
|
||||
|
||||
// The target is the trailing token; everything before it is the item name.
|
||||
fields := strings.Fields(args)
|
||||
if len(fields) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!give <item> @user` — e.g. `!give Health Potion @alex`.")
|
||||
}
|
||||
targetRaw := fields[len(fields)-1]
|
||||
itemQuery := strings.TrimSpace(strings.TrimSuffix(args, targetRaw))
|
||||
|
||||
target, ok := p.ResolveUser(targetRaw, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Couldn't find a user matching %q. Mention them or use their exact name.", targetRaw))
|
||||
}
|
||||
if target == ctx.Sender {
|
||||
return p.SendDM(ctx.Sender, "Regifting to yourself? That's just moving things around.")
|
||||
}
|
||||
|
||||
// Recipient must have completed !setup.
|
||||
rc, rerr := LoadDnDCharacter(target)
|
||||
if rerr != nil || rc == nil || rc.PendingSetup {
|
||||
name := p.DisplayName(target)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s hasn't set up an adventurer yet — they need to run `!setup` before they can receive gifts.", name))
|
||||
}
|
||||
|
||||
day := time.Now().UTC().Format("2006-01-02")
|
||||
if sent := giftCountToday(ctx.Sender, day); sent >= giftDailyCap {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already sent %d gifts today (the daily limit). Try again tomorrow.", sent))
|
||||
}
|
||||
|
||||
inv, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't reach your inventory. Try again in a moment.")
|
||||
}
|
||||
match, reason := pickGiftableItem(inv, itemQuery)
|
||||
if match == nil {
|
||||
if reason == "" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", itemQuery))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
|
||||
tax := int(math.Round(float64(match.Value) * giftTaxRate))
|
||||
if tax > 0 {
|
||||
if bal := p.euro.GetBalance(ctx.Sender); bal < float64(tax) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Gifting **%s** carries a €%d handling fee (5%% of its value), and you only have €%.0f.", match.Name, tax, bal))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(tax), "adventure_gift_tax") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
|
||||
}
|
||||
}
|
||||
|
||||
moved, err := transferInventoryItem(match.ID, ctx.Sender, target)
|
||||
if err != nil || !moved {
|
||||
if tax > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(tax), "adventure_gift_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't hand the item over. Nothing changed — try again in a moment.")
|
||||
}
|
||||
if tax > 0 {
|
||||
communityPotAdd(tax)
|
||||
trackTaxPaid(ctx.Sender, tax)
|
||||
}
|
||||
_ = logGift(ctx.Sender, target, match.Name, match.Value, day)
|
||||
|
||||
senderName := p.DisplayName(ctx.Sender)
|
||||
recipName := p.DisplayName(target)
|
||||
_ = p.SendDM(target, fmt.Sprintf("🎁 **%s** sent you **%s**! It's in your inventory now — `!adventure inventory` to take a look.", senderName, match.Name))
|
||||
|
||||
remaining := giftDailyCap - giftCountToday(ctx.Sender, day)
|
||||
feeNote := ""
|
||||
if tax > 0 {
|
||||
feeNote = fmt.Sprintf(" (€%d handling fee to the community pot)", tax)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🎁 Sent **%s** to %s%s. %d gift(s) left today.", match.Name, recipName, feeNote, remaining))
|
||||
}
|
||||
145
internal/plugin/adventure_gifting_test.go
Normal file
145
internal/plugin/adventure_gifting_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestIsGiftableItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ string
|
||||
want bool
|
||||
}{
|
||||
{"consumable", true},
|
||||
{"MasterworkGear", true},
|
||||
{"ArenaGear", true},
|
||||
{"magic_item", false},
|
||||
{"treasure", false},
|
||||
{"ore", false},
|
||||
{"card", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, reason := isGiftableItem(AdvItem{Type: c.typ, Name: "X"})
|
||||
if got != c.want {
|
||||
t.Errorf("isGiftableItem(%q) = %v, want %v (reason %q)", c.typ, got, c.want, reason)
|
||||
}
|
||||
if !got && reason == "" {
|
||||
t.Errorf("isGiftableItem(%q) rejected with no reason", c.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickGiftableItem: a non-giftable item must not shadow a giftable one that
|
||||
// also matches the query.
|
||||
func TestPickGiftableItem(t *testing.T) {
|
||||
inv := []AdvItem{
|
||||
{ID: 1, Name: "Dragon Scale", Type: "material", Tier: 5, Value: 9000},
|
||||
{ID: 2, Name: "Dragon Draught", Type: "consumable", Tier: 1, Value: 200},
|
||||
}
|
||||
// "dragon" matches the Scale first (higher tier), but the Draught is the
|
||||
// giftable one — pick it, don't refuse.
|
||||
got, reason := pickGiftableItem(inv, "dragon")
|
||||
if got == nil {
|
||||
t.Fatalf("pickGiftableItem returned nil (reason %q), want the giftable Draught", reason)
|
||||
}
|
||||
if got.Name != "Dragon Draught" {
|
||||
t.Errorf("picked %q, want Dragon Draught", got.Name)
|
||||
}
|
||||
|
||||
// Only a non-giftable match → refuse with a reason.
|
||||
onlyMagic := []AdvItem{{ID: 3, Name: "Ring of Power", Type: "magic_item", Value: 5000}}
|
||||
got, reason = pickGiftableItem(onlyMagic, "ring")
|
||||
if got != nil || reason == "" {
|
||||
t.Errorf("magic-only match: got=%v reason=%q, want nil with a refusal reason", got, reason)
|
||||
}
|
||||
|
||||
// No match at all → nil, empty reason.
|
||||
got, reason = pickGiftableItem(inv, "nonexistent")
|
||||
if got != nil || reason != "" {
|
||||
t.Errorf("no match: got=%v reason=%q, want nil/empty", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferInventoryItem: an item moves owners exactly, and a wrong-owner
|
||||
// transfer is a no-op.
|
||||
func TestTransferInventoryItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Health Potion", Type: "consumable", Tier: 1, Value: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if len(inv) != 1 {
|
||||
t.Fatalf("setup: alice has %d items, want 1", len(inv))
|
||||
}
|
||||
itemID := inv[0].ID
|
||||
|
||||
// Wrong owner can't move it.
|
||||
if moved, err := transferInventoryItem(itemID, bob, alice); err != nil || moved {
|
||||
t.Fatalf("wrong-owner transfer moved=%v err=%v, want no-op", moved, err)
|
||||
}
|
||||
|
||||
// Real owner moves it to bob.
|
||||
moved, err := transferInventoryItem(itemID, alice, bob)
|
||||
if err != nil || !moved {
|
||||
t.Fatalf("transfer moved=%v err=%v, want success", moved, err)
|
||||
}
|
||||
if a, _ := loadAdvInventory(alice); len(a) != 0 {
|
||||
t.Errorf("alice still has %d items after gifting", len(a))
|
||||
}
|
||||
b, _ := loadAdvInventory(bob)
|
||||
if len(b) != 1 || b[0].Name != "Health Potion" {
|
||||
t.Errorf("bob's inventory = %+v, want the potion", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferUnvaultsItem: gifting a currently-vaulted item lands it in the
|
||||
// recipient's active pack, not a phantom vault slot.
|
||||
func TestTransferUnvaultsItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice2:test.invalid")
|
||||
bob := id.UserID("@bob2:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Elixir", Type: "consumable", Tier: 1, Value: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if _, err := setItemVaulted(alice, inv[0].ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transferInventoryItem(inv[0].ID, alice, bob); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Bob sees it in active inventory (not vault).
|
||||
if b, _ := loadAdvInventory(bob); len(b) != 1 {
|
||||
t.Errorf("bob active inventory = %d, want 1 (item un-vaulted on gift)", len(b))
|
||||
}
|
||||
if bv, _ := loadAdvVault(bob); len(bv) != 0 {
|
||||
t.Errorf("bob vault = %d, want 0", len(bv))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGiftDailyCapCounting: logGift increments the day's count and the count is
|
||||
// day-scoped.
|
||||
func TestGiftDailyCapCounting(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
sender := id.UserID("@giver:test.invalid")
|
||||
recip := id.UserID("@taker:test.invalid")
|
||||
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != 0 {
|
||||
t.Fatalf("initial count = %d, want 0", n)
|
||||
}
|
||||
for i := 0; i < giftDailyCap; i++ {
|
||||
if err := logGift(sender, recip, "Potion", 100, "2026-07-10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != giftDailyCap {
|
||||
t.Errorf("count = %d, want %d", n, giftDailyCap)
|
||||
}
|
||||
// A different day is a fresh allowance.
|
||||
if n := giftCountToday(sender, "2026-07-11"); n != 0 {
|
||||
t.Errorf("next-day count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user