diff --git a/adventure_expansion_spec.md b/adventure_expansion_spec.md new file mode 100644 index 0000000..92e7ba7 --- /dev/null +++ b/adventure_expansion_spec.md @@ -0,0 +1,476 @@ +# 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 + +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)** — additive fields with real sources, no deploy-order + hazard, no security surface. Smallest thing that visibly improves the page. +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. + +Open, not covered by any of the five: the live sheet poll patches only +HP/AC/room/supplies/threat (`templates/who.html:287-290`). A richer sheet is a +Pete-only change.