N4/E1: T4 Estate vault — 10-slot protected item storage

The Tier-4 "Established" home now unlocks a vault: `!adventure vault
[store|take] <item>` moves items in and out of a 10-slot pool that shelters
them from `!sell all`, crafting, and combat consumption. It is also E2
gifting's prerequisite plumbing.

Implemented as a single `in_vault` flag on adventure_inventory rather than a
parallel table: a stowed item keeps its identity (id, temper, everything) and
loadAdvInventory filters it out, so a vaulted item drops out of every play
surface with one flag change and comes back untouched. DEFAULT 0 = "in play",
correct for every pre-existing row, so no bootstrap backfill.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 13:44:32 -07:00
parent d01d3e277a
commit 1de2004f26
5 changed files with 327 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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 <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records

View File

@@ -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`)

View File

@@ -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] <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 "):
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 <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)
}

View File

@@ -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")
}
}