Files
Pete/adventure_expansion_spec.md

33 KiB
Raw Blame History

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:

{
  "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):

{
  "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 / ZoneEdgeinternal/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:

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:

{
  "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:

{
  "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 DerivePlayerStatsCombatStats (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.

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

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.cssgrep '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).