Files
Pete/adventure_expansion_spec.md

703 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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?" (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 <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`).