From 7e596977549f2eb5e0995f9c6b2853e870076e6b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:04:18 -0700 Subject: [PATCH] adventure: tell Pete whether a backpack item beats what's worn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/peteclient/client.go | 35 ++++++ internal/plugin/pete_compare_test.go | 169 +++++++++++++++++++++++++++ internal/plugin/pete_roster.go | 143 +++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 internal/plugin/pete_compare_test.go 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.