# 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::", "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`) 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:"` 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. ## 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`).