703 lines
31 KiB
Markdown
703 lines
31 KiB
Markdown
# 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`).
|