# Adventure expansion — gogobee↔Pete contract spec

Status: **proposed**, nothing implemented. Written 2026-07-17.

Covers the five gogobee-blocked asks behind the Adventure expansion:

1. `treasure_found` fact → treasures on the trophy case
2. LLM-authored dispatches (`headline`/`lede` on the fact)
3. Room graph + current room → dungeon map with fog of war
4. Richer item view → item inspection
5. `equip_orders` queue → equipment management from the web

Pete is a read-only mirror of gogobee and stays one. Nothing here opens a route
from Pete into the game box's network; ask 5 uses the poll-queue pattern that
mischief and casino escrow already established, so the direction of travel
remains gogobee→Pete.

---

## 0. The constraints that shape all five

**Deploy Pete first, always.** An unknown `event_type` is a 400 on Pete
(`internal/web/adventure.go:83`, via `renderAdventure` returning `ok=false`).
gogobee's sender retries a 400 with backoff to `maxAttempts=8` and then **parks
the bulletin forever** (`internal/peteclient/client.go:79-86`, and the warning at
`internal/plugin/pete.go:279-281`). So for ask 1 the first treasures are lost
permanently if the order is reversed. This is not a style preference; it is the
one sequencing rule in this document that silently destroys data.

Additive *fields* on an existing fact type are safe in either order — Pete's
`AdvFact` decode ignores unknown JSON keys, and gogobee omits empty ones. Only
new `event_type` values carry the parking hazard.

**Snapshots are dropped on failure, never queued** (`client.go:320-327`). A
retried snapshot is a lie about a moment that has passed, and the silence is
what makes Pete's `rosterStaleAfter = 12 * time.Minute` timer honest. Asks 3
and 4 ride snapshots, so they inherit this: a dropped push means a stale map,
not a wrong one.

**Facts are a log; snapshots are current state.** `adventure_events` is the one
adventure table that is a log (`internal/storage/schema.go:75-93`). Anything
that needs to be *counted* must arrive as a fact. Anything that describes *now*
belongs on a snapshot. Ask 1 is a fact because "treasures found" is a tally;
asks 3 and 4 are snapshots because a map and an item sheet describe the present.

**`INSERT OR IGNORE` on the guid is the durable idempotency guarantee**
(`internal/storage/adventure.go:45-58`). The `IsGUIDSeen` check ahead of it is a
courtesy that can race. Any new fact type inherits both.

---

## 1. `treasure_found` fact

**This is a gogobee feature, not a contract gap.** No loot fact is emitted
anywhere today; loot is room-local narration. The contract below is the easy
half. The work is in gogobee.

### Emit site

`dropZoneLoot` (`internal/plugin/dnd_zone_loot.go:492`) is the single grant point
for monster/boss/elite drops and already has `userID`, `zoneID`, `monster`, and
`isBoss`/`isElite` in hand. `dropMagicItemLoot` (`:590`) is the magic-item branch
and additionally has the `MagicItem` and its `LootTier`.

Route it through `emitFact` (`pete.go:169-187`), **not** `peteclient.Emit`
directly — `emitFact` is what enforces the opt-out anonymization and derives
`Actors` from the final names. Pete's `factGuard` rejects a `Subject` absent
from `Actors`, so bypassing it produces a silent 400.

### Which finds are newsworthy

Not every copper piece is a bulletin. **The filter already exists**: the tier-5
treasure `RoomAnnounce` path (`internal/plugin/adventure.go:1377-1390`, strings
in `adventure_flavor_treasure.go:276-279`) fires only when a treasure def is
flagged story-grade. Reuse that flag as the emit condition rather than inventing
a second notion of "notable".

Suggested tiering, matching the existing `zone_first`/`zone_clear` pattern:

- `tier: "bulletin"` — a story-grade find.
- `tier: "priority"` — a realm-first hoard, via `claimRealmFirst(kind, target)`
  (`pete.go:249-258`). The flavor file already exists
  (`internal/flavor/zone_first_hoard_flavor.go`).

### Payload

New `event_type` on the existing `Fact` struct (`peteclient/client.go:33-51`).
No new fields — the existing ones carry it:

```json
{
  "guid":        "treasure_found:<token>:<ts>",
  "event_type":  "treasure_found",
  "tier":        "bulletin",
  "actors":      ["Josie"],
  "subject":     "Josie",
  "zone":        "The Ossuary",
  "region":      "...",
  "level":       7,
  "stakes":      "Crown of the Drowned King",
  "outcome":     "legendary",
  "occurred_at": 1752710400
}
```

- `guid` prefix **must** equal `event_type` (`client.go:34`) — it becomes a
  public permalink path on Pete (`advPermalink`, `internal/web/adventure.go:351`).
- `subject` is the finder. Never populate `opponent` — Pete only credits
  trophies where `Subject == name` (`storage/adventure.go:178`), pinned by
  `TestTrophyCaseIgnoresOpponentCredit`.
- `stakes` carries the item name. This is a reuse of an existing free-text field
  rather than a new `item` field; if that reads as a stretch, add `item` instead
  and treat it as an additive field (safe in either deploy order).
- `outcome` carries the rarity/loot tier, so Pete can weight a legendary find
  above a common one without parsing the name.

### Pete side

- `renderAdventure` gains a `treasure_found` case (`internal/web/adventure.go:377-491`).
- `storage.TrophyCase` (`storage/adventure.go:84`) gains a treasure tally. Note
  the standing caveat at `storage/adventure.go:108-114`: **a limit on the fetch
  truncates a tally, not a list.** The counter must read `EventsBySubject(name, 0)`.
- The who template's "The record" section (`templates/who.html:83-155`) gains a
  fourth stat tile alongside BossKills/ZoneClears/Deaths/Retreats.
- The `storage/adventure.go:75-83` comment saying no loot fact exists on the
  wire gets deleted, since it will no longer be true.

### Deliberately not doing

Counting the vault. A *bought* sword is not a trophy, and inventory is
current-state with no "found it in X on day 3". Treasures are only countable
going forward, same as every other trophy.

---

## 2. LLM-authored dispatches (`headline` / `lede`)

**SHIPPED 2026-07-17: Pete `eeeac08`, gogobee `22b7949`. All tests green both
sides, gofmt-clean, screenshot-verified (realistic + max-length prose both sit
cleanly on the card), NOT deployed.** Built as written below (additive
`headline`/`lede`, prose-guard, template fallback), with one architecture
decision the spec did not surface:

> **This section contradicts `pete_adventure_news_voice.md`**, the older
> foundational doc, which says *Pete* owns the voice and gogobee is "compute,
> not ghostwriter" — the flow there is Pete builds a voiced prompt and calls a
> generic gogobee inference endpoint. That needs a **Pete→gogobee route**, which
> `roster.go:23-25` forbids ("no route back into the game box's network"). The
> network constraint kills the voice-doc design, so §2's gogobee-authors-and-
> pushes model is the only one that fits one-way delivery. Confirmed with the
> owner before building. Cost: the warm-reporter voice now lives in gogobee's
> prompt (`pete_dispatch_voice.go`), softening "gogobee never sees Pete" — a
> deliberate, owner-approved trade, not an oversight.

The template-rendered dispatches are all identical and read as boilerplate.
gogobee's LLM writes the prose instead; Pete's templates stop being the renderer
and **become the safety net**.

### Payload

Two additive, optional fields on `Fact` (`peteclient/client.go:33-51`):

```json
{
  "headline": "Josie went into the Ossuary alone and came back with the crown.",
  "lede":     "..."
}
```

Both `omitempty`. Additive fields on existing event types, so deploy order does
not matter.

### The security inversion — do not miss this

`internal/web/adventure.go:374-376` claims template-only output is "safe and
reproducible", and `factGuard` (`:358-372`) is what makes that true. **factGuard
only checks the structured `Subject`/`Opponent` fields.** That is safe today
only because Pete's own templates can print nothing Pete did not interpolate.

The moment gogobee's LLM authors the prose, factGuard is validating fields that
are **no longer the thing being rendered**. Character names are player-chosen,
so a hallucinated or injected name walks onto a public page. This is a live
injection surface, not a theoretical one.

**Required, in the same change that accepts `headline`/`lede` — not a
follow-up:**

- A **prose-level guard**. Pete holds the full roster. Reject any prose
  containing a known character name that is absent from `Actors`, and fall back
  to `renderAdventure` for that fact.
- The fallback is why **the templates must not be deleted**. They are the
  degraded path for every fact the guard rejects, plus every fact from a gogobee
  that sends no prose.
- Length caps on both fields, enforced before render. The 64 KiB body cap
  (`adventure.go:79`) is not a prose cap.
- The guard runs at ingest, not render, so a rejected dispatch is rejected once
  rather than on every page view.

A rejected dispatch should log loudly. It means either gogobee's LLM
hallucinated a name or someone found an injection path, and both are worth
seeing.

### Open question

Whether the LLM prose is persisted alongside the template output or replaces it
in `stories`. Persisting both costs a column and buys the ability to A/B the
voice and to re-render if the guard later tightens. Recommend persisting both.

---

## 3. Room graph + current room

**The graph already exists and is richer than the ask assumed.** This is mostly
"stop throwing the structure away."

### What exists in gogobee today

- `ZoneGraph` / `ZoneNode` / `ZoneEdge` — `internal/plugin/zone_graph.go:84,47,71`.
- `ZoneNodeKind` (`:17-29`): entry, exploration, trap, elite, boss, harvest,
  rest_camp, secret, fork, merge.
- `ZoneEdge` (`:71-74`) carries `From`/`To`/`Lock`/`Weight`, with
  `ZoneEdgeLockKind` (`:60-68`): none, perception_check, key_required,
  level_min, region_clear, stat_check.
- `DungeonRun` (`dnd_zone_run.go:58-85`) tracks `CurrentNode` and
  **`VisitedNodes`** — which is exactly the fog-of-war mask, already computed.
- Per-zone graphs in `zone_graph_*.go` (~14 zones), nav in `zone_graph_nav.go`.

### What the wire drops

`pete_roster.go:343-349` flattens all of it into a display string at the last
moment:

```go
Room: fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
```

Worse, `CurrentRoom` is a **legacy linear path index derived from
`VisitedNodes`** and is no longer persisted (`dnd_zone_run.go:50-52, 433-442`).
`RoomsTraversed != CurrentRoom+1` once backtracking is involved (`:81-85`). So
the current string is a lossy projection of a graph onto a line that no longer
exists.

### Payload

Extend `RosterDetail` (`peteclient/client.go:260-272`), which lands in Pete's
`whoDetail` (`internal/web/who.go:25-44`). Keep the existing `room` string for
back-compat and add structure beside it:

```json
{
  "room": "4 / 9",
  "map": {
    "zone_id":      "ossuary",
    "current_node": "n7",
    "visited":      ["n1", "n3", "n7"],
    "nodes": [
      {"id": "n1", "kind": "entry"},
      {"id": "n3", "kind": "trap"},
      {"id": "n7", "kind": "elite"},
      {"id": "n9", "kind": "boss"}
    ],
    "edges": [
      {"from": "n1", "to": "n3", "lock": "none"},
      {"from": "n3", "to": "n7", "lock": "perception_check"},
      {"from": "n7", "to": "n9", "lock": "key_required"}
    ]
  }
}
```

### Fog of war is a server-side cut, not a client-side style

**Send only what `VisitedNodes` justifies.** A node the adventurer has not
reached, plus edges leading out of visited nodes with the destination's `kind`
withheld. Do not send the full graph and grey it out in CSS — the map is a
public page, and "view source to find the boss room" is not fog of war.

Concretely: include a node if it is visited, or if it is one hop from a visited
node. For the one-hop ring, send `{"id": "n9", "kind": "unknown"}` — the player
knows a door is there, not what is behind it.

This means the payload is per-adventurer and cannot be shared or cached across
players, which is already true of `RosterDetail`.

### Cost

A zone graph is small (tens of nodes), and this rides the existing 2-minute
roster push (`pete_roster.go:32`), so no new request. The 1 MiB roster cap and
500-entry limit (`internal/web/roster.go:40`) are worth re-checking against 500
adventurers each carrying a subgraph — that is the one real risk here, and it
argues for the one-hop cut on size grounds as well as secrecy.

### Pete side

- `whoDetail` gains the `map` field; `decodeWhoDetail` (`who.go:252`) handles it.
- New map rendering on the who page. The 60s live poll
  (`templates/who.html:305`) patches `#who-room` today; it would also patch the
  map. Note the poll patches **public detail only** by design (`who.go:160-163`).

---

## 4. Richer item view

**SHIPPED 2026-07-17** — gogobee `b6d4e4c`, Pete `4ce025a`. Neither deployed.

The section below is kept as written, because three of its claims were wrong
and the corrections are the useful part. What actually shipped:

- **`Equipped []ItemView` on `PlayerDetail`, which this section never asked
  for.** Equipping *moves* the row from `adventure_inventory` into
  `magic_item_equipped` (`magic_items_gameplay.go:679-690`) — the two sets are
  disjoint. So `attuned` on a backpack item, below, can never be true: bond
  state there isn't false, it's *undefined*. The real gap was that worn items
  weren't sent at all. `equippedViews` is where `Attuned` means something.
- **Stat modifiers ARE modeled** — see "What does not exist", which is wrong.
  `magicItemEffectFor`/`magicItemEffectSummary` (`magic_items_gameplay.go:211`,
  `:538`) produce a player-facing delta ("+15% damage, -8% damage taken"). The
  fear below — that a display-only approximation lies the first time it and the
  engine disagree — doesn't apply: this *is* the engine's summary, the same
  function the game speaks with, so there's nothing to drift from. Sent as
  `effect`. Raw per-stat numbers still aren't modeled and still aren't sent.
- **`skill_source` must be filtered, not forwarded.** The column is dual-use:
  `"mining"` on masterwork gear, and the internal `"magic_item:<id>"` registry
  pointer on magic-item rows (`magic_items_gameplay.go:521-533`). Sending it raw
  puts gogobee IDs on a page, and Pete can't tell the two apart to filter them.
  The push site sends only the skill name.

Pete-side note: an ItemView can't tell you which panel it's in, and that decides
whether an unbonded attunement item reads as "inert" (worn, doing nothing) or
"needs a bond" (just not worn yet). `internal/web/who.go`'s `itemRow` carries it.

Still open from this ask: **equipping from the web** is ask 5, not this one.

---

Partly buildable now, partly not. Being precise about which is which.

### What exists and is being dropped

`itemViews` (`pete_roster.go:210-225`) sends five of `AdvItem`'s fields
(`internal/plugin/adventure_character.go:150-159`) and drops two:

- **`Slot`** (`EquipmentSlot`, non-empty for MasterworkGear)
- **`SkillSource`** (string, non-empty for MasterworkGear)

Descriptions exist, on other structs:

- **`MagicItem.Desc`** (`magic_items.go:35-46`) — first-sentence SRD summary.
  `MagicItem` also has `Kind`, `Rarity`, and **`Attunement bool`**.
- **`EquipmentDef.Description`** (`adventure_character.go:172-177`) — shop
  equipment.

### What does not exist

**Stat modifiers and requirements are not modeled.** Not "not sent" — not
modeled. There are no attack/AC/ability deltas on `AdvItem` or `MagicItem`.
Effects are keyed off Tier/Slot/SkillSource and resolved at
`combat_stats.go:36` and `combat_bridge.go:396,489`.

So "+2 to hit" cannot be sent, because nothing computes it. Shipping it means
either inventing a modifier model in gogobee's engine first, or deriving a
display-only approximation from Tier/Slot at the push site — the latter is a
lie the first time the engine and the display disagree, and is not recommended.

**Requirements** likewise do not exist as data. Attunement is the closest real
thing (`MagicItem.Attunement`, with a bond cap of 3), and it is worth surfacing
on its own terms rather than dressed up as a generic requirement.

### Payload

Extend `ItemView` (`peteclient/client.go:356-362` → Pete's
`internal/storage/detail.go:23-29`) with fields that have a real source today:

```json
{
  "name": "Crown of the Drowned King",
  "type": "MasterworkGear",
  "tier": 5,
  "value": 4200,
  "temper": "...",
  "slot": "helmet",
  "skill_source": "...",
  "desc": "...",
  "attunement": true,
  "attuned": false
}
```

All additive and `omitempty`; rides the private `/api/ingest/detail` push
(`client.go:393-402`), so deploy order does not matter. `desc` is populated from
`MagicItem.Desc` or `EquipmentDef.Description` depending on the item's origin —
the push site resolves it, since `AdvItem` rows carry no description of their own.

`attunement` (does it need a bond) and `attuned` (does it have one) are distinct
and both matter to a player deciding what to wear, given the cap of 3.

### Deferred

Stat modifiers, until gogobee models them. Tracked as a gogobee engine change,
not a contract change.

---

## 5. `equip_orders` queue

The one ask that needs a write path. **No new network route**: Pete grows a
queue table and a pending/verdict endpoint pair, gogobee grows a poller. Same
shape as mischief.

### Which existing queue to copy: mischief, not escrow

The two existing queues solve the ladder **differently**, and the difference is
load-bearing.

**Escrow** (`storage/games.go:54-58`) has a real `claimed` state, a `claimed_at`,
and a stale-reoffer window (`PendingEscrow`, `:209`). It needs them because real
money moves, the claim response is the authoritative amount to move against, and
a player is watching a spinner (hence `Flush` at `pete_games.go:97` and a 3s
poll).

**Mischief** (`storage/mischief.go:37-42`) has **no `claimed` state at all**.
`/api/mischief/claim` is misleadingly named — it is a *verdict* endpoint. A
gogobee that dies mid-work leaves the row `pending`; it is re-offered next poll;
the guid makes the replay a no-op. **The poll loop is its own retry.** No
`claimed_at`, no stale window, no reconciliation.

An equip order has neither of escrow's forcing properties. Nobody watches a
spinner (the UI must say "queued" regardless), and no money moves. More
importantly an equip is **naturally idempotent** — "sword in weapon slot" is a
desired end state, not a delta, so a replay converges rather than double-applies.
That is exactly mischief's precondition.

**Copy mischief.** Do not inherit the misleading name: call the endpoint
`verdict`.

### Ladder

```
pending -> applied
        -> rejected_slot_taken
        -> rejected_not_owned
        -> rejected_requirements
```

Terminal reasons are enumerated rather than free-text so the web UI can say
something specific. `detail` carries the prose.

### Table

`equip_orders`, modeled on `mischief_orders` (`internal/storage/schema.go:125-139`):

| column | notes |
|---|---|
| `guid` | PK. Pete mints it at insert, so the player sees a reference instantly. |
| `owner_sub` | OIDC subject. `json:"-"` — never crosses to gogobee, same as `MischiefOrder.BuyerSub`. |
| `owner_localpart` | Matrix localpart, via `buyerLocalpart` (`web/mischief.go:21-23`). |
| `character_name` | Who is being dressed. |
| `item_id` | `AdvItem.ID`. |
| `slot` | Target slot, or empty for unequip. |
| `action` | `equip` / `unequip`. |
| `status` | The ladder above. |
| `detail` | Verdict prose. |
| `created_at`, `updated_at` | |

Indexes `(status, created_at)` and `(owner_sub, created_at DESC)`, matching
mischief.

### Endpoints

Bearer-authed, outside the sign-in block, beside the mischief pair
(`server.go:255-256`):

- **`GET /api/equip/pending`** → `[]storage.EquipOrder`. Never `null` — return
  `[]` (`web/mischief.go:204-205`). Cap at 50 (`mischiefPollLimit`).
- **`POST /api/equip/verdict`** → `{"guid":..., "status":..., "detail":...}`,
  16 KiB cap. Response: the resolved row.
  - Missing guid → 400. Unknown guid → 400 + loud `slog.Error`. Bad status → 400.
  - **400 is contractual** (`web/mischief.go:222-225`): it parks the row on
    gogobee's side rather than retrying forever.

Buyer-side (OIDC, registered only when `adv.Enabled`): an order endpoint plus a
"my orders" list, mirroring `handleMischiefOrder` / `handleMischiefOrders`.
Burst guard 20/hour keyed on OIDC sub, explicitly anti-spam only — the real
eligibility check is gogobee's at verdict time.

### The idempotency mechanic to copy verbatim

`ResolveMischiefOrder` (`storage/mischief.go:115-132`): the UPDATE is guarded
`WHERE guid = ? AND status = 'pending'`, then it **unconditionally reads the row
back**. A first verdict, a retried verdict, and a missing row all take one path,
and the read-back is the authoritative answer. This is the whole reason mischief
can skip the `claimed` state.

### gogobee side

A poller modeled on `pete_mischief.go`: 30s interval, 20s timeout, poll errors
at `Debug` (a Pete predating the feature 404s here, which is not an error).
Apply through the existing equip path so `reconcileMagicAttunements`
(`magic_items_gameplay.go`) still runs — bond capacity and the cap of 3 are that
code's business, not the queue's. Short-circuit on the stamped order guid the
way `placeWebMischief` does.

### Honest UI

An equip lands on gogobee's next poll tick, up to 30s out. **The UI shows
"queued" and never claims it landed.** The order row's status is the truth; the
page reflects the row.

---

## Suggested order of work

1. ~~**Ask 4 (items)**~~ — **done** (gogobee `b6d4e4c`, Pete `4ce025a`). Read the
   corrections at the top of §4 before trusting any other section here: this spec
   was written from one side at a time, and every claim it got wrong was one that
   only breaks when you read both sides together. Assume the same of §§1-3, 5.
2. **Ask 3 (map)** — the graph exists; the work is the one-hop cut and the
   rendering. Check the roster size cap.
3. **Ask 5 (equip)** — the architectural step, but a well-trodden one now.
4. **Ask 1 (treasures)** — gated on gogobee emitting loot at all. Pete's handler
   deploys first.
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?" (SHIPPED 2026-07-17)

**SHIPPED 2026-07-17 (session 5):** gogobee `7e59697`, Pete `6c6de56`. All tests
green both sides, gofmt-clean, screenshot-verified day + night (all six verdict
chips, including the purple `new` chip the theme-contrast history warned about).
NOT deployed. Additive private `Compare` field → safe deploy order either way.

**The one both-sides error, and it overturned a settled design decision: the game
only ever wears one ring.** `DnDSlotRing2` is declared and looped over as a valid
slot, but nothing in live code assigns to it — every ring in the registry is
`Slot: ring_1`, and the only equip path (`applyMagicEquip`, the same one ask 5's
web button drives) equips to `mi.Slot`. So "weaker of the two worn rings" (the
verified-at-scope proposal below) describes a trade that can't happen. The shipped
behaviour: a backpack ring compares against the `ring_1` occupant — which collapses
the ring special-case entirely, since **every** item's compare target is just
`mi.Slot`. Even a section written after verifying both repos carried the class of
error the rest of the spec did; the ring-slot subtlety was flagged "confirm before
building" and the confirmation is what caught it. (Owner was asked; chose ring_1.)

Everything else below shipped as written: strict-dominance verdict, engine-computed
deltas over tempered effects (reusing `magicItemEffectFor`, no Pete-side math), the
inert override that counts bonds *after* the slot's occupant is evicted, always-
visible verdict chip (no hover — phones don't have one) with deltas beside it. Chip
colours mix a fixed hue into `--ink`/`--card` so they survive all four phases by
construction (the map's trick), NOT a `dark:` variant. The mobile hover→tap
disclosure the spec proposed was dropped: deltas are short (≤3 chips), so showing
them inline is simpler and needs no JS.

---



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 <slot>,"
  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.

### `text-[color:var(--ink)]/NN` may be a no-op — unresolved, verify first

Found while fixing the night-phase contrast (`7d2d991`), **not** fixed there.
The evidence points two ways and I could not reconcile it:

- No such rule exists in the built `output.css` — `grep 'text-\[color:var(--ink)\]/55'`
  finds nothing, and all 21 `var(--ink)` references in the output come from
  hand-written component CSS, not from these classes.
- The item description in the Worn panel computes to a **fixed `rgb(74,46,42)`
  that does not change with the phase** — that is dawn's `--ink` as a literal,
  from a source I never located.
- Tailwind v3 cannot apply an opacity modifier to an arbitrary `var()` colour,
  which would explain all of the above.
- **But** a night-phase screenshot showed that same text rendering as readable
  cream, which contradicts the computed value outright.

So: probably a pre-existing site-wide no-op, silently dropping the muted-text
styling wherever it appears — `who.html` uses `/45`, `/50`, `/55` throughout,
and it is not confined to the new item panels. Treat the above as a lead, not a
finding. **Start by explaining the screenshot/computed contradiction** — one of
the two observations is measuring the wrong thing, and which one decides whether
there is a bug here at all.

Measuring this in the live page is booby-trapped, and each of these cost time:
reading an element's own background returns its translucent chip fill rather
than its backdrop (walk up from `parentElement`); a page reload resets
`data-phase` to the server-rendered value (`data-phase-lock` in `layout.html:2`
stops the clock script, not a reload); and reading the card colour and the text
colour in different ticks silently mixes two phases. A screenshot was ground
truth every time a computed value was not.

If it is real, the fix is the same shape as `--warn`: a phase variable, or a
hand-written utility — not a Tailwind arbitrary-value class with an opacity
modifier. See the `pete_theme_contrast` note for why `dark:` is never the answer
here (`darkMode` is unconfigured, so it follows the OS, not Pete's phase —
`status.html` and `channel.html` still have that bug).

### Richer live sheet

The live sheet poll patches only HP/AC/room/supplies/threat
(`templates/who.html:287-290`).
