diff --git a/internal/db/db.go b/internal/db/db.go index f2bb351..39b17e7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 4824781..0b8e8fb 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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|]", Category: "Games"}, {Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|]", 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 @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") { diff --git a/internal/plugin/adventure_gifting.go b/internal/plugin/adventure_gifting.go new file mode 100644 index 0000000..0bc3402 --- /dev/null +++ b/internal/plugin/adventure_gifting.go @@ -0,0 +1,200 @@ +package plugin + +import ( + "fmt" + "math" + "strings" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// N4/E2 item gifting. `!give @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 @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 @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)) +} diff --git a/internal/plugin/adventure_gifting_test.go b/internal/plugin/adventure_gifting_test.go new file mode 100644 index 0000000..669cb55 --- /dev/null +++ b/internal/plugin/adventure_gifting_test.go @@ -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) + } +}