diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 7add4a7..ebcd7a2 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -348,17 +348,37 @@ type PlayerDetail struct { Token string `json:"token"` Inventory []ItemView `json:"inventory,omitempty"` Vault []ItemView `json:"vault,omitempty"` + Equipped []ItemView `json:"equipped,omitempty"` House HouseView `json:"house"` Pets []PetView `json:"pets,omitempty"` } -// ItemView is one backpack or vault item for the private inventory panel. +// ItemView is one item in the private panels — backpack, vault, or worn. +// +// Slot/SkillSource/Desc/Effect are display resolutions done at the push site, +// because an adventure_inventory row carries none of them: descriptions live on +// MagicItem/EquipmentDef, and the combat delta is computed, never stored. +// +// SkillSource is only the player-facing skill a masterwork piece draws on +// ("mining"). Inventory rows smuggle "magic_item:" through the same column +// as an internal registry pointer; that is not a fact about the item and never +// goes on the wire. +// +// Attunement (does it need a bond) and Attuned (does it have one) are distinct: +// with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding +// what to wear needs to see the difference. type ItemView struct { - Name string `json:"name"` - Type string `json:"type"` - Tier int `json:"tier"` - Value int64 `json:"value"` - Temper int `json:"temper,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Tier int `json:"tier"` + Value int64 `json:"value"` + Temper int `json:"temper,omitempty"` + Slot string `json:"slot,omitempty"` + SkillSource string `json:"skill_source,omitempty"` + Desc string `json:"desc,omitempty"` + Effect string `json:"effect,omitempty"` + Attunement bool `json:"attunement,omitempty"` + Attuned bool `json:"attuned,omitempty"` } // HouseView is the owner's housing summary. diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 3aa5a53..cb9361e 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "sort" + "strings" "time" "gogobee/internal/db" @@ -202,25 +204,78 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) { if items, err := loadAdvVault(uid); err == nil { pd.Vault = itemViews(items) } + pd.Equipped = equippedViews(uid) snap.Players = append(snap.Players, pd) } return snap, nil } +// itemViews renders inventory or vault rows for the private panel, resolving +// the display facts the row itself doesn't carry. +// +// Attuned is always false here and that is not an omission: equipping *moves* +// the row out of adventure_inventory into magic_item_equipped, so nothing in a +// backpack can hold a bond. Worn items come from equippedViews instead. func itemViews(items []AdvItem) []peteclient.ItemView { if len(items) == 0 { return nil } out := make([]peteclient.ItemView, 0, len(items)) for _, it := range items { - out = append(out, peteclient.ItemView{ + v := peteclient.ItemView{ Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Temper: it.Temper, + Slot: string(it.Slot), + } + // SkillSource is dual-use: a real skill name on masterwork gear, an + // internal registry pointer on magic-item rows. Only the former is a + // fact about the item; the latter is plumbing and stays home. + if !strings.HasPrefix(it.SkillSource, "magic_item:") { + v.SkillSource = it.SkillSource + } + if mi, ok := magicItemFromAdvItem(it); ok { + eff := temperedItem(mi, it.Temper) + v.Slot = string(eff.Slot) + v.Desc = eff.Desc + v.Effect = magicItemEffectSummary(eff) + v.Attunement = eff.Attunement + } else if it.Slot != "" { + // Shop equipment resolves by (slot, tier) — Name is decorative. + v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description + } + out = append(out, v) + } + return out +} + +// equippedViews returns the magic items the player is actually wearing. This is +// the only place Attuned means anything: the bond lives on the equipped row, and +// with a cap of dndMagicItemAttuneLimit a worn item can be inert. +func equippedViews(uid id.UserID) []peteclient.ItemView { + equipped, err := loadEquippedMagicItems(uid) + if err != nil || len(equipped) == 0 { + return nil + } + out := make([]peteclient.ItemView, 0, len(equipped)) + for _, e := range equipped { + eff := e.Effective() + out = append(out, peteclient.ItemView{ + Name: eff.Name, + Type: string(eff.Kind), + Value: int64(eff.Value), + Temper: e.Temper, + Slot: string(e.Slot), + Desc: eff.Desc, + Effect: magicItemEffectSummary(eff), + Attunement: eff.Attunement, + Attuned: e.Attuned, }) } + // Map iteration is random; the panel must not reshuffle every 60s poll. + sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot }) return out } diff --git a/internal/plugin/pete_roster_test.go b/internal/plugin/pete_roster_test.go index 2980c70..79bbdb9 100644 --- a/internal/plugin/pete_roster_test.go +++ b/internal/plugin/pete_roster_test.go @@ -1,6 +1,7 @@ package plugin import ( + "sort" "testing" "time" @@ -125,3 +126,101 @@ func TestRosterTokenIsNotAnEventToken(t *testing.T) { t.Error("board token not stable — the row would churn identity every snapshot") } } + +// pickMagicItem returns a registry item matching want, so these tests read the +// real registry rather than pinning an item ID that a later SRD dump could drop. +func pickMagicItem(t *testing.T, want func(MagicItem) bool) MagicItem { + t.Helper() + var ids []string + for id := range magicItemRegistry { + ids = append(ids, id) + } + sort.Strings(ids) // map order is random; a flaky pick is a flaky test + for _, id := range ids { + if mi := magicItemRegistry[id]; want(mi) { + return mi + } + } + t.Skip("no registry item matches this shape") + return MagicItem{} +} + +// TestItemViewsKeepsTheRegistryPointerHome is the leak guard. SkillSource is two +// different things depending on the row: a player-facing skill name on +// masterwork gear ("mining"), and the internal "magic_item:" pointer that +// resolves an inventory row back to the registry. Only the first is a fact about +// the item. Sending the second would put gogobee's internal IDs on a page, and +// Pete would have no way to tell them apart to filter them out. +func TestItemViewsKeepsTheRegistryPointerHome(t *testing.T) { + newBoredomTestDB(t) + mi := pickMagicItem(t, func(m MagicItem) bool { return m.Desc != "" && m.Slot != "" }) + + views := itemViews([]AdvItem{ + {Name: mi.Name, Type: "magic_item", Tier: 3, Value: 100, + SkillSource: "magic_item:" + mi.ID}, + {Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300, + Slot: SlotWeapon, SkillSource: "mining"}, + }) + + if views[0].SkillSource != "" { + t.Errorf("the magic_item registry pointer went out on the wire: %q", views[0].SkillSource) + } + if views[0].Desc != mi.Desc { + t.Errorf("desc = %q, want the registry's %q", views[0].Desc, mi.Desc) + } + if views[0].Effect == "" { + t.Error("a magic item should carry the engine's own effect summary") + } + if views[1].SkillSource != "mining" { + t.Errorf("masterwork skill source = %q, want it kept", views[1].SkillSource) + } +} + +// TestItemViewsNeverClaimABackpackBond: equipping *moves* the row out of +// adventure_inventory into magic_item_equipped, so nothing in a backpack can +// hold a bond. Attuned must stay false here whatever the item wants, or the +// panel tells a player an unworn item is working for them. +func TestItemViewsNeverClaimABackpackBond(t *testing.T) { + newBoredomTestDB(t) + mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" }) + + v := itemViews([]AdvItem{{Name: mi.Name, Type: "magic_item", Tier: 3, + SkillSource: "magic_item:" + mi.ID}})[0] + + if !v.Attunement { + t.Error("an attunement item should say it wants a bond") + } + if v.Attuned { + t.Error("a backpack item claimed a bond it cannot hold") + } +} + +// TestEquippedViewsCarryBondState: the worn set is the only place Attuned means +// anything, and the only way the page can show that a worn item is sitting inert +// against the cap of three. +func TestEquippedViewsCarryBondState(t *testing.T) { + newBoredomTestDB(t) + uid := id.UserID("@josie:example.org") + mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" }) + + if err := equipMagicItem(uid, mi.Slot, mi.ID, false, 0); err != nil { + t.Fatalf("equip: %v", err) + } + views := equippedViews(uid) + if len(views) != 1 { + t.Fatalf("equipped views = %d, want 1", len(views)) + } + if views[0].Attuned { + t.Error("an inert worn item was reported as bonded") + } + if views[0].Slot != string(mi.Slot) { + t.Errorf("slot = %q, want %q", views[0].Slot, mi.Slot) + } + + if err := equipMagicItem(uid, mi.Slot, mi.ID, true, 0); err != nil { + t.Fatalf("re-equip bonded: %v", err) + } + if !equippedViews(uid)[0].Attuned { + t.Error("a bonded worn item was reported as inert") + } +}