diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index b231951..57e1f4f 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -423,6 +423,41 @@ type ItemView struct { Effect string `json:"effect,omitempty"` Attunement bool `json:"attunement,omitempty"` Attuned bool `json:"attuned,omitempty"` + // Compare pairs a backpack magic item against whatever is worn in the slot it + // would equip into, so the owner can tell an upgrade from a sidegrade without + // eyeballing two opaque effect strings. Set only on backpack magic items (the + // ones that carry an equip ID); worn and vault rows never have it. Owner-private, + // rides detail_json — no migration, no public surface. + Compare *ItemCompare `json:"compare,omitempty"` +} + +// ItemCompare is the per-stat verdict for equipping a backpack magic item over +// what is currently worn in its slot. gogobee computes it (the power math folds +// in tempering and bond availability, which live in the engine); Pete only +// renders it and does no arithmetic. +type ItemCompare struct { + // Verdict is one of: upgrade, downgrade, sidegrade, same, new, inert. + // upgrade every changed stat a gain + // downgrade every changed stat a loss + // sidegrade mixed — some better, some worse; no winner claimed + // same no stat differs + // new the target slot is empty; equipping fills it + // inert needs a bond and none is free — wearing it would do nothing + Verdict string `json:"verdict"` + // VsName is the worn item being replaced; "" when Verdict is new (empty slot). + VsName string `json:"vs_name,omitempty"` + // VsSlot is the slot the item would land in (e.g. "ring_1"). + VsSlot string `json:"vs_slot,omitempty"` + // Deltas is one entry per changed stat, each flagged better/worse. Engine- + // rendered player-facing text; Pete draws arrows off Better and does no math. + Deltas []ItemDelta `json:"deltas,omitempty"` +} + +// ItemDelta is one stat's change between the candidate item and the worn item. +type ItemDelta struct { + Label string `json:"label"` + Better bool `json:"better"` + Text string `json:"text"` } // HouseView is the owner's housing summary. diff --git a/internal/plugin/pete_compare_test.go b/internal/plugin/pete_compare_test.go new file mode 100644 index 0000000..eb7ca49 --- /dev/null +++ b/internal/plugin/pete_compare_test.go @@ -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) + } + }) +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index be75e01..af037fd 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -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.