From 3230939c51119951ab2bc7868769a8e9072e7c0d Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:42:18 -0700 Subject: [PATCH] adventure: scope ask 6 (item upgrade comparison), verified both sides --- adventure_expansion_spec.md | 133 ++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/adventure_expansion_spec.md b/adventure_expansion_spec.md index 6191211..33b29d8 100644 --- a/adventure_expansion_spec.md +++ b/adventure_expansion_spec.md @@ -522,6 +522,139 @@ page reflects the row. 5. **Ask 2 (LLM dispatches)** — last, because the prose guard is the only piece here that fails *publicly* if it is wrong. +## 6. Item comparison — "is this backpack item an upgrade?" (scoped 2026-07-17) + +A sixth ask, scoped after all five shipped. On the owner's own page, hovering a +backpack magic item shows a **compare card**: the item currently worn in that +same slot and the per-stat deltas, so the player can answer "is this better than +what I've got on?" without scrolling between two panels and eyeballing two +opaque effect strings. + +**This section was written AFTER verifying both repos** (unlike §§1-5, which were +written a side at a time and were wrong wherever the two sides disagreed). The +findings below are checked against the gogobee engine, not assumed — but treat +the *design decisions* (verdict semantics, ring handling) as proposals, not +settled, and re-confirm the file refs at build time. + +### The load-bearing constraint (same one as §4) + +**Pete must not compute the comparison.** The ask-4 lesson: a display-only power +approximation *lies the moment it disagrees with the engine*, and character math +lives in gogobee. That is doubly true here — the comparison depends on three +things Pete does not hold: + +- **Tempering.** An item's effective power folds in its per-instance temper + (`EquippedMagicItem.Effective()` → `temperedItem`, `magic_items_gameplay.go:252`). + The worn item and the backpack item each temper differently; the diff must be + over *tempered* effects. +- **Bond availability.** An attunement item worn with no free bond is **inert** — + equipping it changes nothing. So the honest verdict for such an item is "would + sit inert until you free a bond," not "downgrade." Same distinction §4/ask 4 + drew, resurfacing on the compare card. Bond state is engine-side. +- **Which slot it lands in** (rings — see below). + +So gogobee computes the comparison and pushes it; Pete renders it. **Simple diff +arithmetic, engine-side inputs.** + +### What the comparison is built from — this is the good news + +The Worn-panel items are **magic items**, and their power is a *structured +numeric struct*, not just the opaque `Effect` string: +`magicItemEffect{DamageBonus float64, DamageReductMult float64, FlatDmgStart int, +InitiativeBias float64, MaxHP int}` (`magic_items_gameplay.go:179`), derived by +`magicItemEffectFor(mi)` (`:212`) keyed on Kind+Rarity with a per-item overlay. +So a real field-by-field diff exists. **Diff the structs, never parse the +`Effect` summary string** — the summary drops any field that is zero, so parsing +it back loses deltas. + +Direction of "better" per field (the verdict logic needs this): +`DamageBonus` ↑, `FlatDmgStart` ↑, `InitiativeBias` ↑, `MaxHP` ↑ are gains; +`DamageReductMult` is a multiplier on damage taken in `(0,1]`, so **lower is +better** (0.90 = −10% damage taken). + +### NOT the masterwork gear + +There are two equip systems. This ask is the **magic-item** Worn/backpack panels +(`MagicItem` / `DnDSlot`, effects via `magicItemEffectFor`). The other is +masterwork `EquipmentSlot`/`AdvEquipment` gear feeding `DerivePlayerStats` → +`CombatStats` (`combat_stats.go`). Do **not** cross the streams — different +slots, different power model, scoped out in ask 5. A compare only ever pairs a +magic item against a magic item. + +### Payload + +Additive `Compare` sub-object on the backpack `ItemView` (`peteclient.ItemView`, +mirrored in Pete `storage.ItemView`). Owner-private — it rides the PlayerDetail +`detail_json` blob (**no migration**, no new endpoint, no public exposure), same +channel as §4. `omitempty` → safe deploy order either way. Item names are +game-authored (not player names), so **no prose-guard / injection surface** like +§2. + +```json +"compare": { + "verdict": "upgrade | downgrade | sidegrade | same | new | inert", + "vs_name": "Ring of Protection", // worn item being replaced; "" when verdict=new + "vs_slot": "ring_2", // the slot it would land in (see rings) + "deltas": [ + {"label": "damage", "better": true, "text": "+3% damage"}, + {"label": "hp", "better": false, "text": "-4 HP"} + ] +} +``` + +`deltas` is engine-rendered player-facing text (like `magicItemEffectSummary`), +one entry per changed field, each flagged `better`. Pete renders chips/arrows off +`better`; it does no math. + +### Verdict semantics (proposal — confirm) + +Different stats are **not fungible** — the engine cannot say +3% damage beats +−4 HP. So use **strict dominance**, and let the player judge the mixed case: + +- **upgrade** — every delta a gain (≥), at least one strict. +- **downgrade** — every delta a loss (≤), at least one strict. +- **sidegrade** — mixed. *This is the case the string-only view could never + show, and the reason this ask exists.* Show all deltas; claim no winner. +- **same** — no field differs. +- **new** — the target slot is empty; frame as "equips into an empty ," + all-gain but labelled a fill, not a replacement. +- **inert** — attunement item, no free bond: overrides the stat verdict, because + wearing it does nothing until a bond frees. + +### Rings are double-slot (the subtlety to decide) + +`DnDSlotRing1` / `DnDSlotRing2` (`dnd_equipment.go:24-25`) — two ring slots. A +backpack ring has to compare against *something*. Proposal: compare against the +**weaker of the two worn rings** (the one a sensible player would replace); if +either ring slot is empty, verdict `new` (fills the empty slot). Flag: this is a +judgement call, not derivable — confirm before building. + +### Integration points + +- **gogobee:** `itemViews` (`pete_roster.go:219`) builds the backpack ItemViews + but does **not** currently know the equipped set (it is shared with the vault + call). The compare needs the equipped magic items in scope — pass them into a + backpack-only post-pass, or a new `itemViewsWithCompare(inv, equipped)`. Attach + `Compare` for backpack magic items only (vault rows carry an id today but no + button; compare follows the same "backpack only" rule). Reuse + `magicItemEffectFor` on `temperedItem(...)` for both sides; read bonds from + `loadEquippedMagicItems`/the attune cap. +- **Pete:** `itemRow` (`internal/web/who.go:111`) already carries per-panel state; + add the compare card to the backpack row template in `who.html`. Hover-tooltip + on desktop. **Mobile has no hover** — render an always-visible verdict chip + (⬆ upgrade / ⬇ downgrade / ⇄ sidegrade / ✦ new / ⚠ inert) and put the full + deltas behind a tap. New Tailwind classes → rebuild+commit `output.css` (the + committed-artifact trap from every prior ask). Screenshot-verify day + night. + +### Cost / honest scope + +The engine work is small (one diff function over an existing struct, plus the +ring/bond decisions). The UI is the bulk: a tooltip that also degrades to a +tap-target on touch, styled for both phases. No new table, no migration, no new +endpoint, no public surface. Deploy order free (additive private field). + +--- + ## Follow-ups, not covered by any of the five Both Pete-only.