mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
125
internal/plugin/adventure_vault.go
Normal file
125
internal/plugin/adventure_vault.go
Normal 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)
|
||||
}
|
||||
138
internal/plugin/adventure_vault_test.go
Normal file
138
internal/plugin/adventure_vault_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user