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:
prosolis
2026-07-17 06:44:45 -07:00
parent 479f77b9c5
commit b6d4e4ccec
3 changed files with 181 additions and 7 deletions

View File

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