Files
gogobee/internal/plugin/pete_compare_test.go
prosolis 7e59697754 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.
2026-07-17 10:04:18 -07:00

170 lines
6.4 KiB
Go

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