adventure: tell Pete whether a backpack item beats what's worn

For each backpack magic item, compute the per-stat diff against whatever
occupies the slot it would equip into (mi.Slot — the same slot the web
Equip button targets, so the card describes the trade that actually
happens). The verdict is strict dominance: all-gain is an upgrade, all-loss
a downgrade, mixed a sidegrade with no winner claimed (the case the two
opaque effect strings could never show). Empty slot reads 'new'; an
attunement item with no free bond reads 'inert', which overrides the stat
verdict because wearing it does nothing.

The diff is over tempered effects on both sides and reuses the engine's
own magicItemEffectFor, so nothing here re-derives power math that could
drift from the game. Rides an additive Compare object on the private
backpack ItemView — no migration, no endpoint, no public surface.

Rings collapse to the same path: every ring equips to ring_1 (ring_2 is
declared but never assigned by any live code), so a backpack ring simply
compares against the ring_1 occupant.
This commit is contained in:
prosolis
2026-07-17 10:04:18 -07:00
parent 22b7949791
commit 7e59697754
3 changed files with 347 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
package plugin
import (
"testing"
"gogobee/internal/peteclient"
)
// weapon/ring/wondrous helpers build a MagicItem the codified effect formula
// (magicItemEffectFor) can score without touching the DB.
func mkItem(kind MagicItemKind, rarity DnDRarity, slot DnDSlot) MagicItem {
return MagicItem{ID: string(kind) + "_" + string(rarity), Kind: kind, Rarity: rarity, Slot: slot}
}
// TestMagicItemDeltasDirection pins the "which way is better" call for each stat,
// including DamageReductMult where LOWER is the improvement.
func TestMagicItemDeltasDirection(t *testing.T) {
neutral := magicItemEffect{DamageReductMult: 1.0}
t.Run("more damage is better", func(t *testing.T) {
d := magicItemDeltas(magicItemEffect{DamageBonus: 0.15, DamageReductMult: 1.0}, magicItemEffect{DamageBonus: 0.10, DamageReductMult: 1.0})
if len(d) != 1 || d[0].Label != "damage" || !d[0].Better || d[0].Text != "+5% damage" {
t.Fatalf("got %+v", d)
}
})
t.Run("less damage taken is better", func(t *testing.T) {
// cand mult 0.90 (blocks 10%) vs worn neutral 1.0 (blocks nothing).
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.90}, neutral)
if len(d) != 1 || d[0].Label != "defense" || !d[0].Better || d[0].Text != "-10% damage taken" {
t.Fatalf("got %+v", d)
}
})
t.Run("weaker armor reads as worse", func(t *testing.T) {
// cand blocks less than worn: mult rises, damage taken goes up.
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.96}, magicItemEffect{DamageReductMult: 0.90})
if len(d) != 1 || d[0].Better || d[0].Text != "+6% damage taken" {
t.Fatalf("got %+v", d)
}
})
t.Run("hp and opening damage", func(t *testing.T) {
d := magicItemDeltas(
magicItemEffect{DamageReductMult: 1.0, MaxHP: 10, FlatDmgStart: 3},
magicItemEffect{DamageReductMult: 1.0, MaxHP: 6, FlatDmgStart: 5},
)
byLabel := map[string]itemDelta{}
for _, x := range d {
byLabel[x.Label] = itemDelta{x.Better, x.Text}
}
if v := byLabel["hp"]; v.text != "+4 HP" || !v.better {
t.Errorf("hp: %+v", v)
}
if v := byLabel["opening"]; v.text != "-2 opening damage" || v.better {
t.Errorf("opening: %+v", v)
}
})
t.Run("sub-percent change is dropped", func(t *testing.T) {
// 0.004 fraction = 0.4% → rounds to 0% → not a visible delta.
if d := magicItemDeltas(
magicItemEffect{DamageBonus: 0.104, DamageReductMult: 1.0},
magicItemEffect{DamageBonus: 0.100, DamageReductMult: 1.0},
); len(d) != 0 {
t.Fatalf("expected no visible delta, got %+v", d)
}
})
}
type itemDelta struct {
better bool
text string
}
// TestCompareVerdict pins strict-dominance classification and the overrides.
func TestCompareVerdict(t *testing.T) {
gain := []peteclient.ItemDelta{{Better: true}}
loss := []peteclient.ItemDelta{{Better: false}}
mixed := []peteclient.ItemDelta{{Better: true}, {Better: false}}
cases := []struct {
name string
deltas []peteclient.ItemDelta
empty, inrt bool
want string
}{
{"all gains", gain, false, false, "upgrade"},
{"all losses", loss, false, false, "downgrade"},
{"mixed", mixed, false, false, "sidegrade"},
{"no change", nil, false, false, "same"},
{"empty slot", gain, true, false, "new"},
{"inert overrides upgrade", gain, false, true, "inert"},
{"inert overrides new", gain, true, true, "inert"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := compareVerdict(c.deltas, c.empty, c.inrt); got != c.want {
t.Errorf("compareVerdict(%s) = %q, want %q", c.name, got, c.want)
}
})
}
}
// TestMagicItemCompareIntegration drives the whole builder against equipped maps.
func TestMagicItemCompareIntegration(t *testing.T) {
rareWpn := mkItem(MagicItemWeapon, RarityRare, DnDSlotMainHand) // +15% damage
uncWpn := mkItem(MagicItemWeapon, RarityUncommon, DnDSlotMainHand) // +10% damage
t.Run("upgrade over a weaker worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: uncWpn}}
c := magicItemCompare(rareWpn, 0, eq)
if c.Verdict != "upgrade" || c.VsName != uncWpn.Name || c.VsSlot != "main_hand" {
t.Fatalf("got %+v", c)
}
})
t.Run("downgrade under a stronger worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
if c := magicItemCompare(uncWpn, 0, eq); c.Verdict != "downgrade" {
t.Fatalf("got %+v", c)
}
})
t.Run("same as an identical worn weapon", func(t *testing.T) {
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
c := magicItemCompare(rareWpn, 0, eq)
if c.Verdict != "same" || len(c.Deltas) != 0 {
t.Fatalf("got %+v", c)
}
})
t.Run("new into an empty slot names no worn item", func(t *testing.T) {
c := magicItemCompare(rareWpn, 0, map[DnDSlot]EquippedMagicItem{})
if c.Verdict != "new" || c.VsName != "" || len(c.Deltas) == 0 {
t.Fatalf("got %+v", c)
}
})
t.Run("attunement item with no free bond is inert", func(t *testing.T) {
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
ring.Attunement = true
// Three bonds spent elsewhere; the ring slot is empty. Wearing it does nothing.
eq := map[DnDSlot]EquippedMagicItem{
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
DnDSlotCloak: {Slot: DnDSlotCloak, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotCloak), Attuned: true},
}
if c := magicItemCompare(ring, 0, eq); c.Verdict != "inert" {
t.Fatalf("got %+v", c)
}
})
t.Run("evicting the worn occupant frees its bond, so not inert", func(t *testing.T) {
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
ring.Attunement = true
wornRing := mkItem(MagicItemRing, RarityUncommon, DnDSlotRing1)
wornRing.Attunement = true
// Three bonds spent — but one of them is the ring the candidate would replace.
eq := map[DnDSlot]EquippedMagicItem{
DnDSlotRing1: {Slot: DnDSlotRing1, Item: wornRing, Attuned: true},
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
}
if c := magicItemCompare(ring, 0, eq); c.Verdict == "inert" {
t.Fatalf("bond freed by eviction, should not be inert: %+v", c)
}
})
}

View File

@@ -200,6 +200,9 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
}
if items, err := loadAdvInventory(uid); err == nil {
pd.Inventory = itemViews(items)
if equipped, err := loadEquippedMagicItems(uid); err == nil {
attachInventoryCompares(pd.Inventory, items, equipped)
}
}
if items, err := loadAdvVault(uid); err == nil {
pd.Vault = itemViews(items)
@@ -259,6 +262,146 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
return out
}
// attachInventoryCompares fills the Compare card on every backpack magic item —
// the ones that carry an equip id, which is exactly the set the web Equip button
// acts on (equippedViews/vault rows are excluded by construction). views and items
// are index-aligned: itemViews appends one view per item and skips none.
func attachInventoryCompares(views []peteclient.ItemView, items []AdvItem, equipped map[DnDSlot]EquippedMagicItem) {
for i := range views {
if views[i].ID == 0 {
continue // no equip id → mundane gear or unslotted curio → no button, no compare
}
mi, ok := magicItemFromAdvItem(items[i])
if !ok || mi.Slot == "" {
continue
}
views[i].Compare = magicItemCompare(mi, items[i].Temper, equipped)
}
}
// magicItemCompare pairs a candidate backpack item against whatever is worn in
// the slot it would equip into (mi.Slot — the same slot applyMagicEquip targets,
// so the card describes the trade the Equip button actually makes). The diff is
// over *tempered* effects on both sides; bond availability decides the inert case.
func magicItemCompare(cand MagicItem, temper int, equipped map[DnDSlot]EquippedMagicItem) *peteclient.ItemCompare {
if cand.Slot == "" {
return nil
}
candEff := magicItemEffectFor(temperedItem(cand, temper))
worn, wornExists := equipped[cand.Slot]
if wornExists && worn.Item.ID == "" {
wornExists = false // an empty EquippedMagicItem is not a real occupant
}
// An empty slot compares against neutral: DamageReductMult is a multiplier, so
// its neutral is 1.0, not the zero value (0 would read as -100% damage taken).
wornEff := magicItemEffect{DamageReductMult: 1.0}
vsName := ""
if wornExists {
wornEff = magicItemEffectFor(worn.Effective())
vsName = worn.Effective().Name
}
deltas := magicItemDeltas(candEff, wornEff)
// Inert: the item wants a bond and none is free. Equipping evicts the slot's
// occupant first, so an attuned occupant frees its own bond — count post-swap.
inert := false
if cand.Attunement {
bonds := countAttunedMagicItems(equipped)
if wornExists && worn.Attuned {
bonds--
}
inert = bonds >= dndMagicItemAttuneLimit
}
return &peteclient.ItemCompare{
Verdict: compareVerdict(deltas, !wornExists, inert),
VsName: vsName,
VsSlot: string(cand.Slot),
Deltas: deltas,
}
}
// compareVerdict classifies a set of deltas by strict dominance. Different stats
// are not fungible — the engine can't say +3% damage beats -4 HP — so a mixed
// result is a sidegrade with no winner claimed, which is the whole reason the
// card exists. inert and new override the stat verdict.
func compareVerdict(deltas []peteclient.ItemDelta, empty, inert bool) string {
if inert {
return "inert" // wearing it does nothing until a bond frees; stat diff is moot
}
if empty {
return "new"
}
if len(deltas) == 0 {
return "same"
}
gains, losses := 0, 0
for _, d := range deltas {
if d.Better {
gains++
} else {
losses++
}
}
switch {
case losses == 0:
return "upgrade"
case gains == 0:
return "downgrade"
default:
return "sidegrade"
}
}
// magicItemDeltas returns one entry per stat that visibly changes between the
// candidate and the worn item. It diffs the structured effect fields, never the
// summary string (which drops zero fields and would lose deltas). A change too
// small to show at the rendered precision is omitted, so the verdict matches what
// the player sees.
func magicItemDeltas(cand, worn magicItemEffect) []peteclient.ItemDelta {
var d []peteclient.ItemDelta
// DamageBonus / DamageReductMult are fractions rendered as whole percents.
if pct := (cand.DamageBonus - worn.DamageBonus) * 100; roundedPct(pct) != 0 {
d = append(d, peteclient.ItemDelta{Label: "damage", Better: pct > 0, Text: signedPct(pct, "damage")})
}
// DamageReductMult is a multiplier on damage TAKEN, so lower is better. Express
// the change as damage taken: a positive number means you take more (worse).
if taken := (cand.DamageReductMult - worn.DamageReductMult) * 100; roundedPct(taken) != 0 {
d = append(d, peteclient.ItemDelta{Label: "defense", Better: taken < 0, Text: signedPct(taken, "damage taken")})
}
if diff := cand.FlatDmgStart - worn.FlatDmgStart; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "opening", Better: diff > 0, Text: signedInt(diff, "opening damage")})
}
if diff := cand.MaxHP - worn.MaxHP; diff != 0 {
d = append(d, peteclient.ItemDelta{Label: "hp", Better: diff > 0, Text: signedInt(diff, "HP")})
}
if cand.InitiativeBias != worn.InitiativeBias {
faster := cand.InitiativeBias > worn.InitiativeBias
text := "slower to act"
if faster {
text = "faster to act"
}
d = append(d, peteclient.ItemDelta{Label: "speed", Better: faster, Text: text})
}
return d
}
// roundedPct is the whole-percent a delta renders as; used to drop sub-percent
// noise so the verdict never disagrees with the displayed chips.
func roundedPct(v float64) int {
if v < 0 {
return int(v - 0.5)
}
return int(v + 0.5)
}
func signedPct(v float64, noun string) string { return fmt.Sprintf("%+d%% %s", roundedPct(v), noun) }
func signedInt(v int, noun string) string { return fmt.Sprintf("%+d %s", v, noun) }
// 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.