diff --git a/internal/db/db.go b/internal/db/db.go index 47453d4..f2bb351 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -357,6 +357,13 @@ func runMigrations(d *sql.DB) error { // row and no bootstrap backfill is needed. The column is a read guard: // loadCombatParticipants is skipped entirely when it reads 1. `ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`, + // N4/E1 housing vault (gogobee_engagement_plan.md §E1). A Tier-4 Estate + // unlocks a 10-slot vault that shelters items from sale/use. Rather than a + // parallel table, a stowed item keeps its identity (id, temper, everything) + // and flips in_vault=1; loadAdvInventory filters it back out, so a vaulted + // item drops out of sell/craft/combat readers as one flag change. DEFAULT 0 + // = "in play", correct for every pre-existing row, so no bootstrap backfill. + `ALTER TABLE adventure_inventory ADD COLUMN in_vault INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index c76cd8c..4824781 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -513,6 +513,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleMasteryCmd(ctx) case lower == "treasures" || strings.HasPrefix(lower, "treasures "): return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures"))) + case lower == "vault" || strings.HasPrefix(lower, "vault "): + return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):])) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -530,6 +532,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots ` + "`!adventure sell `" + ` — Sell an inventory item (or ` + "`sell all`" + `) ` + "`!adventure inventory`" + ` — View your inventory +` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take `" + `) ` + "`!adventure leaderboard`" + ` — View the leaderboard ` + "`!adventure respond`" + ` — Respond to a mid-day event ` + "`!adventure rivals`" + ` — View rival duel records diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 6ef006b..d17f9a4 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -562,7 +562,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) { d := db.Get() rows, err := d.Query(` SELECT id, name, item_type, tier, value, slot, skill_source, temper - FROM adventure_inventory WHERE user_id = ? + FROM adventure_inventory WHERE user_id = ? AND in_vault = 0 ORDER BY tier DESC, value DESC`, string(userID)) if err != nil { return nil, err @@ -623,10 +623,62 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) { func advInventoryCount(userID id.UserID) int { d := db.Get() var count int - _ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count) + _ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count) return count } +// loadAdvVault returns the items the player has stowed in their housing vault +// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of +// play (unsellable, uncraftable, unusable in combat) until it is taken back. +func loadAdvVault(userID id.UserID) ([]AdvItem, error) { + d := db.Get() + rows, err := d.Query(` + SELECT id, name, item_type, tier, value, slot, skill_source, temper + FROM adventure_inventory WHERE user_id = ? AND in_vault = 1 + ORDER BY tier DESC, value DESC`, string(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []AdvItem + for rows.Next() { + var it AdvItem + var slot string + if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil { + return nil, err + } + it.Slot = EquipmentSlot(slot) + items = append(items, it) + } + return items, rows.Err() +} + +// advVaultCount returns how many of a user's items are stowed in the vault. +func advVaultCount(userID id.UserID) int { + d := db.Get() + var count int + _ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count) + return count +} + +// setItemVaulted flips a single inventory row's vault flag. Scoped to the +// owning user so a mistyped id can never move another player's item. +func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) { + v := 0 + if vaulted { + v = 1 + } + res, err := db.Get().Exec( + `UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`, + v, itemID, string(userID)) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + func loadAllAdvCharacters() ([]AdventureCharacter, error) { d := db.Get() rows, err := d.Query(`SELECT user_id FROM player_meta`) diff --git a/internal/plugin/adventure_vault.go b/internal/plugin/adventure_vault.go new file mode 100644 index 0000000..45f5f6d --- /dev/null +++ b/internal/plugin/adventure_vault.go @@ -0,0 +1,125 @@ +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] `. 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 "): + reply = vaultStoreItem(ctx.Sender, strings.TrimSpace(args[len("store "):])) + case strings.HasPrefix(lower, "take "): + reply = vaultTakeItem(ctx.Sender, strings.TrimSpace(args[len("take "):])) + default: + reply = "Usage: `!adventure vault` to view · `!adventure vault store ` · `!adventure vault take `." + } + 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 ` 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 ` 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 `." + } + if advVaultCount(userID) >= vaultCapacity { + return fmt.Sprintf("The vault is full (%d / %d). Take something out first with `!adventure vault take `.", 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 `." + } + 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) +} diff --git a/internal/plugin/adventure_vault_test.go b/internal/plugin/adventure_vault_test.go new file mode 100644 index 0000000..fdd86ca --- /dev/null +++ b/internal/plugin/adventure_vault_test.go @@ -0,0 +1,138 @@ +package plugin + +import ( + "strings" + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func vaultTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +func seedVaultItem(t *testing.T, user id.UserID, name string, value int64) { + t.Helper() + if err := addAdvInventoryItem(user, AdvItem{Name: name, Type: "treasure", Tier: 3, Value: value}); err != nil { + t.Fatal(err) + } +} + +// TestVaultStoreTakeRoundTrip: an item vaulted leaves the active inventory and +// joins the vault; taken back, it reverses exactly. +func TestVaultStoreTakeRoundTrip(t *testing.T) { + vaultTestDB(t) + user := id.UserID("@vault:test.invalid") + seedVaultItem(t, user, "Jeweled Crown", 5000) + + if got := vaultStoreItem(user, "crown"); !strings.Contains(got, "Stowed") { + t.Fatalf("store reply = %q, want a stow confirmation", got) + } + + inv, _ := loadAdvInventory(user) + if len(inv) != 0 { + t.Fatalf("active inventory = %d items, want 0 (item is vaulted)", len(inv)) + } + vault, _ := loadAdvVault(user) + if len(vault) != 1 || vault[0].Name != "Jeweled Crown" { + t.Fatalf("vault = %+v, want the crown", vault) + } + + if got := vaultTakeItem(user, "crown"); !strings.Contains(got, "back into your pack") { + t.Fatalf("take reply = %q, want a retrieval confirmation", got) + } + inv, _ = loadAdvInventory(user) + if len(inv) != 1 { + t.Fatalf("active inventory = %d items, want 1 after take", len(inv)) + } + if n := advVaultCount(user); n != 0 { + t.Fatalf("vault count = %d, want 0", n) + } +} + +// TestVaultCapacity: the 10th item stores, the 11th is refused, and the refusal +// leaves the item in the active inventory. +func TestVaultCapacity(t *testing.T) { + vaultTestDB(t) + user := id.UserID("@vaultcap:test.invalid") + for i := 0; i < vaultCapacity; i++ { + seedVaultItem(t, user, "Gem", 100) + if got := vaultStoreItem(user, "Gem"); !strings.Contains(got, "Stowed") { + t.Fatalf("store %d reply = %q, want success", i, got) + } + } + if n := advVaultCount(user); n != vaultCapacity { + t.Fatalf("vault count = %d, want %d", n, vaultCapacity) + } + + seedVaultItem(t, user, "Overflow Gem", 100) + got := vaultStoreItem(user, "Overflow Gem") + if !strings.Contains(got, "full") { + t.Fatalf("11th store reply = %q, want a full-vault refusal", got) + } + // The refused item is still in play. + inv, _ := loadAdvInventory(user) + if len(inv) != 1 || inv[0].Name != "Overflow Gem" { + t.Fatalf("active inventory = %+v, want the overflow gem left in play", inv) + } +} + +// TestVaultShieldsFromSellAll: `!sell all` liquidates loadAdvInventory, which a +// vaulted item is no longer part of — the whole point of the vault. +func TestVaultShieldsFromSellAll(t *testing.T) { + vaultTestDB(t) + user := id.UserID("@vaultsell:test.invalid") + seedVaultItem(t, user, "Heirloom", 9000) + seedVaultItem(t, user, "Junk", 5) + + if got := vaultStoreItem(user, "Heirloom"); !strings.Contains(got, "Stowed") { + t.Fatalf("store reply = %q", got) + } + // What sell-all would consume: + sellable, _ := loadAdvInventory(user) + if len(sellable) != 1 || sellable[0].Name != "Junk" { + t.Fatalf("sellable inventory = %+v, want only Junk (heirloom sheltered)", sellable) + } +} + +// TestVaultGate: below Tier 4 the vault is locked; at Tier 4 it opens. +func TestVaultGate(t *testing.T) { + locked := &AdventureCharacter{HouseTier: 3} + if ok, msg := vaultUnlocked(locked); ok || msg == "" { + t.Fatalf("tier 3: ok=%v msg=%q, want locked with a refusal", ok, msg) + } + open := &AdventureCharacter{HouseTier: 4} + if ok, msg := vaultUnlocked(open); !ok || msg != "" { + t.Fatalf("tier 4: ok=%v msg=%q, want unlocked", ok, msg) + } +} + +// TestSetItemVaultedIsOwnerScoped: another user's id cannot be flipped. +func TestSetItemVaultedIsOwnerScoped(t *testing.T) { + vaultTestDB(t) + owner := id.UserID("@owner:test.invalid") + intruder := id.UserID("@intruder:test.invalid") + seedVaultItem(t, owner, "Relic", 1000) + items, _ := loadAdvInventory(owner) + if len(items) != 1 { + t.Fatalf("setup: owner inventory = %d, want 1", len(items)) + } + moved, err := setItemVaulted(intruder, items[0].ID, true) + if err != nil { + t.Fatal(err) + } + if moved { + t.Fatalf("intruder moved the owner's item; want no-op") + } + if advVaultCount(owner) != 0 { + t.Fatalf("owner's item was vaulted by an intruder") + } +}