adventure: pin the gogobee contract before Pete assumes it

The five blocked asks all cross the gogobee seam, so spec the wire
before writing code against an imagined shape. Mapping both sides
first changed three answers:

- treasure_found is a gogobee feature, not a contract gap. No loot
  fact exists. But the story-grade filter already does, in the tier-5
  RoomAnnounce path.
- The room graph already exists, typed nodes and locked edges and all.
  pete_roster.go flattens it to "4 / 9" at the last moment, using a
  legacy index that isn't even persisted anymore.
- Item stat modifiers aren't modeled anywhere, so they can't be sent.
  Slot, SkillSource, Desc and Attunement can, today.

Deploy order is a data-loss rule, not a preference: an unknown
event_type 400s, gogobee retries to maxAttempts and parks the bulletin
forever. Pete's handler ships first or the first treasures are gone.

equip_orders copies mischief, not escrow. Mischief has no claimed
state because the poll loop is its own retry and the guid makes a
replay a no-op; an equip is a desired end state, not a delta, so it
converges the same way. Escrow needs claimed because money moves and
someone's watching a spinner. Neither is true here.
This commit is contained in:
prosolis
2026-07-17 00:48:07 -07:00
parent cbe9e67b3e
commit 5fac1630f6

476
adventure_expansion_spec.md Normal file
View File

@@ -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:<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`)
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.