31 KiB
Adventure expansion — gogobee↔Pete contract spec
Status: proposed, nothing implemented. Written 2026-07-17.
Covers the five gogobee-blocked asks behind the Adventure expansion:
treasure_foundfact → treasures on the trophy case- LLM-authored dispatches (
headline/ledeon the fact) - Room graph + current room → dungeon map with fog of war
- Richer item view → item inspection
equip_ordersqueue → 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, viaclaimRealmFirst(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
}
guidprefix must equalevent_type(client.go:34) — it becomes a public permalink path on Pete (advPermalink,internal/web/adventure.go:351).subjectis the finder. Never populateopponent— Pete only credits trophies whereSubject == name(storage/adventure.go:178), pinned byTestTrophyCaseIgnoresOpponentCredit.stakescarries the item name. This is a reuse of an existing free-text field rather than a newitemfield; if that reads as a stretch, additeminstead and treat it as an additive field (safe in either deploy order).outcomecarries the rarity/loot tier, so Pete can weight a legendary find above a common one without parsing the name.
Pete side
renderAdventuregains atreasure_foundcase (internal/web/adventure.go:377-491).storage.TrophyCase(storage/adventure.go:84) gains a treasure tally. Note the standing caveat atstorage/adventure.go:108-114: a limit on the fetch truncates a tally, not a list. The counter must readEventsBySubject(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-83comment 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, whichroster.go:23-25forbids ("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 torenderAdventurefor 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) carriesFrom/To/Lock/Weight, withZoneEdgeLockKind(:60-68): none, perception_check, key_required, level_min, region_clear, stat_check.DungeonRun(dnd_zone_run.go:58-85) tracksCurrentNodeandVisitedNodes— which is exactly the fog-of-war mask, already computed.- Per-zone graphs in
zone_graph_*.go(~14 zones), nav inzone_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
whoDetailgains themapfield;decodeWhoDetail(who.go:252) handles it.- New map rendering on the who page. The 60s live poll
(
templates/who.html:305) patches#who-roomtoday; 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 []ItemViewonPlayerDetail, which this section never asked for. Equipping moves the row fromadventure_inventoryintomagic_item_equipped(magic_items_gameplay.go:679-690) — the two sets are disjoint. Soattunedon 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.equippedViewsis whereAttunedmeans 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 aseffect. Raw per-stat numbers still aren't modeled and still aren't sent. skill_sourcemust 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.MagicItemalso hasKind,Rarity, andAttunement 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. Nevernull— 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.
- Missing guid → 400. Unknown guid → 400 + loud
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
Ask 4 (items)— done (gogobeeb6d4e4c, Pete4ce025a). 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.- Ask 3 (map) — the graph exists; the work is the one-hop cut and the rendering. Check the roster size cap.
- Ask 5 (equip) — the architectural step, but a well-trodden one now.
- Ask 1 (treasures) — gated on gogobee emitting loot at all. Pete's handler deploys first.
- 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.
"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 newitemViewsWithCompare(inv, equipped). AttachComparefor backpack magic items only (vault rows carry an id today but no button; compare follows the same "backpack only" rule). ReusemagicItemEffectForontemperedItem(...)for both sides; read bonds fromloadEquippedMagicItems/the attune cap. - Pete:
itemRow(internal/web/who.go:111) already carries per-panel state; add the compare card to the backpack row template inwho.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+commitoutput.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 21var(--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--inkas 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).