mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
adventure: send Pete what an item actually is, and what's worn
The self-view listed a name, a tier and a price — everything except what a player decides on. The facts were all there, just not on the wire. Three things the contract spec got wrong, found by reading both sides: Equipping *moves* the row out of adventure_inventory into magic_item_equipped, so the two sets are disjoint. The spec's `attuned` on a backpack item can never be true — bond state isn't false there, it's undefined. The real gap was that worn items weren't sent at all: the panel showed the backpack and hid the sword. Hence Equipped, where Attuned means something and an inert item can be seen. Stat modifiers ARE modeled. The spec said they weren't, and that shipping them meant either an engine change or a display-only approximation that lies the first time it disagrees with the engine. But magicItemEffectSummary is the engine's own summary — the same function the game speaks with. Sending it can't drift, because there's nothing to drift from. SkillSource is two different things: "mining" on masterwork gear, and the internal "magic_item:<id>" registry pointer on magic-item rows. Sending it raw would put gogobee's IDs on a page, and Pete couldn't tell them apart to filter them. Only the skill name goes out. Desc and Effect resolve at the push site because an inventory row carries neither — descriptions live on MagicItem/EquipmentDef, and the combat delta is computed, never stored. Shop gear resolves by (slot, tier); Name is decorative there. All additive and omitempty on the private /api/ingest/detail push, so neither side has to deploy first.
This commit is contained in:
@@ -348,17 +348,37 @@ type PlayerDetail struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Inventory []ItemView `json:"inventory,omitempty"`
|
Inventory []ItemView `json:"inventory,omitempty"`
|
||||||
Vault []ItemView `json:"vault,omitempty"`
|
Vault []ItemView `json:"vault,omitempty"`
|
||||||
|
Equipped []ItemView `json:"equipped,omitempty"`
|
||||||
House HouseView `json:"house"`
|
House HouseView `json:"house"`
|
||||||
Pets []PetView `json:"pets,omitempty"`
|
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:<id>" 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 {
|
type ItemView struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Tier int `json:"tier"`
|
Tier int `json:"tier"`
|
||||||
Value int64 `json:"value"`
|
Value int64 `json:"value"`
|
||||||
Temper int `json:"temper,omitempty"`
|
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.
|
// HouseView is the owner's housing summary.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -202,25 +204,78 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
|||||||
if items, err := loadAdvVault(uid); err == nil {
|
if items, err := loadAdvVault(uid); err == nil {
|
||||||
pd.Vault = itemViews(items)
|
pd.Vault = itemViews(items)
|
||||||
}
|
}
|
||||||
|
pd.Equipped = equippedViews(uid)
|
||||||
snap.Players = append(snap.Players, pd)
|
snap.Players = append(snap.Players, pd)
|
||||||
}
|
}
|
||||||
return snap, nil
|
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 {
|
func itemViews(items []AdvItem) []peteclient.ItemView {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]peteclient.ItemView, 0, len(items))
|
out := make([]peteclient.ItemView, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
out = append(out, peteclient.ItemView{
|
v := peteclient.ItemView{
|
||||||
Name: it.Name,
|
Name: it.Name,
|
||||||
Type: it.Type,
|
Type: it.Type,
|
||||||
Tier: it.Tier,
|
Tier: it.Tier,
|
||||||
Value: it.Value,
|
Value: it.Value,
|
||||||
Temper: it.Temper,
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -125,3 +126,101 @@ func TestRosterTokenIsNotAnEventToken(t *testing.T) {
|
|||||||
t.Error("board token not stable — the row would churn identity every snapshot")
|
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:<id>" 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user