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.
19 KiB
Adventure expansion — gogobee↔Pete contract spec
Status: proposed, nothing implemented. Written 2026-07-17.
Covers the five gogobee-blocked asks behind the Adventure expansion:
treasure_foundfact → treasures on the trophy case- LLM-authored dispatches (
headline/ledeon the fact) - Room graph + current room → dungeon map with fog of war
- Richer item view → item inspection
equip_ordersqueue → equipment management from the web
Pete is a read-only mirror of gogobee and stays one. Nothing here opens a route from Pete into the game box's network; ask 5 uses the poll-queue pattern that mischief and casino escrow already established, so the direction of travel remains gogobee→Pete.
0. The constraints that shape all five
Deploy Pete first, always. An unknown event_type is a 400 on Pete
(internal/web/adventure.go:83, via renderAdventure returning ok=false).
gogobee's sender retries a 400 with backoff to maxAttempts=8 and then parks
the bulletin forever (internal/peteclient/client.go:79-86, and the warning at
internal/plugin/pete.go:279-281). So for ask 1 the first treasures are lost
permanently if the order is reversed. This is not a style preference; it is the
one sequencing rule in this document that silently destroys data.
Additive fields on an existing fact type are safe in either order — Pete's
AdvFact decode ignores unknown JSON keys, and gogobee omits empty ones. Only
new event_type values carry the parking hazard.
Snapshots are dropped on failure, never queued (client.go:320-327). A
retried snapshot is a lie about a moment that has passed, and the silence is
what makes Pete's rosterStaleAfter = 12 * time.Minute timer honest. Asks 3
and 4 ride snapshots, so they inherit this: a dropped push means a stale map,
not a wrong one.
Facts are a log; snapshots are current state. adventure_events is the one
adventure table that is a log (internal/storage/schema.go:75-93). Anything
that needs to be counted must arrive as a fact. Anything that describes now
belongs on a snapshot. Ask 1 is a fact because "treasures found" is a tally;
asks 3 and 4 are snapshots because a map and an item sheet describe the present.
INSERT OR IGNORE on the guid is the durable idempotency guarantee
(internal/storage/adventure.go:45-58). The IsGUIDSeen check ahead of it is a
courtesy that can race. Any new fact type inherits both.
1. treasure_found fact
This is a gogobee feature, not a contract gap. No loot fact is emitted anywhere today; loot is room-local narration. The contract below is the easy half. The work is in gogobee.
Emit site
dropZoneLoot (internal/plugin/dnd_zone_loot.go:492) is the single grant point
for monster/boss/elite drops and already has userID, zoneID, monster, and
isBoss/isElite in hand. dropMagicItemLoot (:590) is the magic-item branch
and additionally has the MagicItem and its LootTier.
Route it through emitFact (pete.go:169-187), not peteclient.Emit
directly — emitFact is what enforces the opt-out anonymization and derives
Actors from the final names. Pete's factGuard rejects a Subject absent
from Actors, so bypassing it produces a silent 400.
Which finds are newsworthy
Not every copper piece is a bulletin. The filter already exists: the tier-5
treasure RoomAnnounce path (internal/plugin/adventure.go:1377-1390, strings
in adventure_flavor_treasure.go:276-279) fires only when a treasure def is
flagged story-grade. Reuse that flag as the emit condition rather than inventing
a second notion of "notable".
Suggested tiering, matching the existing zone_first/zone_clear pattern:
tier: "bulletin"— a story-grade find.tier: "priority"— a realm-first hoard, viaclaimRealmFirst(kind, target)(pete.go:249-258). The flavor file already exists (internal/flavor/zone_first_hoard_flavor.go).
Payload
New event_type on the existing Fact struct (peteclient/client.go:33-51).
No new fields — the existing ones carry it:
{
"guid": "treasure_found:<token>:<ts>",
"event_type": "treasure_found",
"tier": "bulletin",
"actors": ["Josie"],
"subject": "Josie",
"zone": "The Ossuary",
"region": "...",
"level": 7,
"stakes": "Crown of the Drowned King",
"outcome": "legendary",
"occurred_at": 1752710400
}
guidprefix must equalevent_type(client.go:34) — it becomes a public permalink path on Pete (advPermalink,internal/web/adventure.go:351).subjectis the finder. Never populateopponent— Pete only credits trophies whereSubject == name(storage/adventure.go:178), pinned byTestTrophyCaseIgnoresOpponentCredit.stakescarries the item name. This is a reuse of an existing free-text field rather than a newitemfield; if that reads as a stretch, additeminstead and treat it as an additive field (safe in either deploy order).outcomecarries the rarity/loot tier, so Pete can weight a legendary find above a common one without parsing the name.
Pete side
renderAdventuregains atreasure_foundcase (internal/web/adventure.go:377-491).storage.TrophyCase(storage/adventure.go:84) gains a treasure tally. Note the standing caveat atstorage/adventure.go:108-114: a limit on the fetch truncates a tally, not a list. The counter must readEventsBySubject(name, 0).- The who template's "The record" section (
templates/who.html:83-155) gains a fourth stat tile alongside BossKills/ZoneClears/Deaths/Retreats. - The
storage/adventure.go:75-83comment saying no loot fact exists on the wire gets deleted, since it will no longer be true.
Deliberately not doing
Counting the vault. A bought sword is not a trophy, and inventory is current-state with no "found it in X on day 3". Treasures are only countable going forward, same as every other trophy.
2. LLM-authored dispatches (headline / lede)
The template-rendered dispatches are all identical and read as boilerplate. gogobee's LLM writes the prose instead; Pete's templates stop being the renderer and become the safety net.
Payload
Two additive, optional fields on Fact (peteclient/client.go:33-51):
{
"headline": "Josie went into the Ossuary alone and came back with the crown.",
"lede": "..."
}
Both omitempty. Additive fields on existing event types, so deploy order does
not matter.
The security inversion — do not miss this
internal/web/adventure.go:374-376 claims template-only output is "safe and
reproducible", and factGuard (:358-372) is what makes that true. factGuard
only checks the structured Subject/Opponent fields. That is safe today
only because Pete's own templates can print nothing Pete did not interpolate.
The moment gogobee's LLM authors the prose, factGuard is validating fields that are no longer the thing being rendered. Character names are player-chosen, so a hallucinated or injected name walks onto a public page. This is a live injection surface, not a theoretical one.
Required, in the same change that accepts headline/lede — not a
follow-up:
- A prose-level guard. Pete holds the full roster. Reject any prose
containing a known character name that is absent from
Actors, and fall back torenderAdventurefor that fact. - The fallback is why the templates must not be deleted. They are the degraded path for every fact the guard rejects, plus every fact from a gogobee that sends no prose.
- Length caps on both fields, enforced before render. The 64 KiB body cap
(
adventure.go:79) is not a prose cap. - The guard runs at ingest, not render, so a rejected dispatch is rejected once rather than on every page view.
A rejected dispatch should log loudly. It means either gogobee's LLM hallucinated a name or someone found an injection path, and both are worth seeing.
Open question
Whether the LLM prose is persisted alongside the template output or replaces it
in stories. Persisting both costs a column and buys the ability to A/B the
voice and to re-render if the guard later tightens. Recommend persisting both.
3. Room graph + current room
The graph already exists and is richer than the ask assumed. This is mostly "stop throwing the structure away."
What exists in gogobee today
ZoneGraph/ZoneNode/ZoneEdge—internal/plugin/zone_graph.go:84,47,71.ZoneNodeKind(:17-29): entry, exploration, trap, elite, boss, harvest, rest_camp, secret, fork, merge.ZoneEdge(:71-74) carriesFrom/To/Lock/Weight, withZoneEdgeLockKind(:60-68): none, perception_check, key_required, level_min, region_clear, stat_check.DungeonRun(dnd_zone_run.go:58-85) tracksCurrentNodeandVisitedNodes— which is exactly the fog-of-war mask, already computed.- Per-zone graphs in
zone_graph_*.go(~14 zones), nav inzone_graph_nav.go.
What the wire drops
pete_roster.go:343-349 flattens all of it into a display string at the last
moment:
Room: fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
Worse, CurrentRoom is a legacy linear path index derived from
VisitedNodes and is no longer persisted (dnd_zone_run.go:50-52, 433-442).
RoomsTraversed != CurrentRoom+1 once backtracking is involved (:81-85). So
the current string is a lossy projection of a graph onto a line that no longer
exists.
Payload
Extend RosterDetail (peteclient/client.go:260-272), which lands in Pete's
whoDetail (internal/web/who.go:25-44). Keep the existing room string for
back-compat and add structure beside it:
{
"room": "4 / 9",
"map": {
"zone_id": "ossuary",
"current_node": "n7",
"visited": ["n1", "n3", "n7"],
"nodes": [
{"id": "n1", "kind": "entry"},
{"id": "n3", "kind": "trap"},
{"id": "n7", "kind": "elite"},
{"id": "n9", "kind": "boss"}
],
"edges": [
{"from": "n1", "to": "n3", "lock": "none"},
{"from": "n3", "to": "n7", "lock": "perception_check"},
{"from": "n7", "to": "n9", "lock": "key_required"}
]
}
}
Fog of war is a server-side cut, not a client-side style
Send only what VisitedNodes justifies. A node the adventurer has not
reached, plus edges leading out of visited nodes with the destination's kind
withheld. Do not send the full graph and grey it out in CSS — the map is a
public page, and "view source to find the boss room" is not fog of war.
Concretely: include a node if it is visited, or if it is one hop from a visited
node. For the one-hop ring, send {"id": "n9", "kind": "unknown"} — the player
knows a door is there, not what is behind it.
This means the payload is per-adventurer and cannot be shared or cached across
players, which is already true of RosterDetail.
Cost
A zone graph is small (tens of nodes), and this rides the existing 2-minute
roster push (pete_roster.go:32), so no new request. The 1 MiB roster cap and
500-entry limit (internal/web/roster.go:40) are worth re-checking against 500
adventurers each carrying a subgraph — that is the one real risk here, and it
argues for the one-hop cut on size grounds as well as secrecy.
Pete side
whoDetailgains themapfield;decodeWhoDetail(who.go:252) handles it.- New map rendering on the who page. The 60s live poll
(
templates/who.html:305) patches#who-roomtoday; it would also patch the map. Note the poll patches public detail only by design (who.go:160-163).
4. Richer item view
Partly buildable now, partly not. Being precise about which is which.
What exists and is being dropped
itemViews (pete_roster.go:210-225) sends five of AdvItem's fields
(internal/plugin/adventure_character.go:150-159) and drops two:
Slot(EquipmentSlot, non-empty for MasterworkGear)SkillSource(string, non-empty for MasterworkGear)
Descriptions exist, on other structs:
MagicItem.Desc(magic_items.go:35-46) — first-sentence SRD summary.MagicItemalso hasKind,Rarity, andAttunement bool.EquipmentDef.Description(adventure_character.go:172-177) — shop equipment.
What does not exist
Stat modifiers and requirements are not modeled. Not "not sent" — not
modeled. There are no attack/AC/ability deltas on AdvItem or MagicItem.
Effects are keyed off Tier/Slot/SkillSource and resolved at
combat_stats.go:36 and combat_bridge.go:396,489.
So "+2 to hit" cannot be sent, because nothing computes it. Shipping it means either inventing a modifier model in gogobee's engine first, or deriving a display-only approximation from Tier/Slot at the push site — the latter is a lie the first time the engine and the display disagree, and is not recommended.
Requirements likewise do not exist as data. Attunement is the closest real
thing (MagicItem.Attunement, with a bond cap of 3), and it is worth surfacing
on its own terms rather than dressed up as a generic requirement.
Payload
Extend ItemView (peteclient/client.go:356-362 → Pete's
internal/storage/detail.go:23-29) with fields that have a real source today:
{
"name": "Crown of the Drowned King",
"type": "MasterworkGear",
"tier": 5,
"value": 4200,
"temper": "...",
"slot": "helmet",
"skill_source": "...",
"desc": "...",
"attunement": true,
"attuned": false
}
All additive and omitempty; rides the private /api/ingest/detail push
(client.go:393-402), so deploy order does not matter. desc is populated from
MagicItem.Desc or EquipmentDef.Description depending on the item's origin —
the push site resolves it, since AdvItem rows carry no description of their own.
attunement (does it need a bond) and attuned (does it have one) are distinct
and both matter to a player deciding what to wear, given the cap of 3.
Deferred
Stat modifiers, until gogobee models them. Tracked as a gogobee engine change, not a contract change.
5. equip_orders queue
The one ask that needs a write path. No new network route: Pete grows a queue table and a pending/verdict endpoint pair, gogobee grows a poller. Same shape as mischief.
Which existing queue to copy: mischief, not escrow
The two existing queues solve the ladder differently, and the difference is load-bearing.
Escrow (storage/games.go:54-58) has a real claimed state, a claimed_at,
and a stale-reoffer window (PendingEscrow, :209). It needs them because real
money moves, the claim response is the authoritative amount to move against, and
a player is watching a spinner (hence Flush at pete_games.go:97 and a 3s
poll).
Mischief (storage/mischief.go:37-42) has no claimed state at all.
/api/mischief/claim is misleadingly named — it is a verdict endpoint. A
gogobee that dies mid-work leaves the row pending; it is re-offered next poll;
the guid makes the replay a no-op. The poll loop is its own retry. No
claimed_at, no stale window, no reconciliation.
An equip order has neither of escrow's forcing properties. Nobody watches a spinner (the UI must say "queued" regardless), and no money moves. More importantly an equip is naturally idempotent — "sword in weapon slot" is a desired end state, not a delta, so a replay converges rather than double-applies. That is exactly mischief's precondition.
Copy mischief. Do not inherit the misleading name: call the endpoint
verdict.
Ladder
pending -> applied
-> rejected_slot_taken
-> rejected_not_owned
-> rejected_requirements
Terminal reasons are enumerated rather than free-text so the web UI can say
something specific. detail carries the prose.
Table
equip_orders, modeled on mischief_orders (internal/storage/schema.go:125-139):
| column | notes |
|---|---|
guid |
PK. Pete mints it at insert, so the player sees a reference instantly. |
owner_sub |
OIDC subject. json:"-" — never crosses to gogobee, same as MischiefOrder.BuyerSub. |
owner_localpart |
Matrix localpart, via buyerLocalpart (web/mischief.go:21-23). |
character_name |
Who is being dressed. |
item_id |
AdvItem.ID. |
slot |
Target slot, or empty for unequip. |
action |
equip / unequip. |
status |
The ladder above. |
detail |
Verdict prose. |
created_at, updated_at |
Indexes (status, created_at) and (owner_sub, created_at DESC), matching
mischief.
Endpoints
Bearer-authed, outside the sign-in block, beside the mischief pair
(server.go:255-256):
GET /api/equip/pending→[]storage.EquipOrder. Nevernull— return[](web/mischief.go:204-205). Cap at 50 (mischiefPollLimit).POST /api/equip/verdict→{"guid":..., "status":..., "detail":...}, 16 KiB cap. Response: the resolved row.- Missing guid → 400. Unknown guid → 400 + loud
slog.Error. Bad status → 400. - 400 is contractual (
web/mischief.go:222-225): it parks the row on gogobee's side rather than retrying forever.
- Missing guid → 400. Unknown guid → 400 + loud
Buyer-side (OIDC, registered only when adv.Enabled): an order endpoint plus a
"my orders" list, mirroring handleMischiefOrder / handleMischiefOrders.
Burst guard 20/hour keyed on OIDC sub, explicitly anti-spam only — the real
eligibility check is gogobee's at verdict time.
The idempotency mechanic to copy verbatim
ResolveMischiefOrder (storage/mischief.go:115-132): the UPDATE is guarded
WHERE guid = ? AND status = 'pending', then it unconditionally reads the row
back. A first verdict, a retried verdict, and a missing row all take one path,
and the read-back is the authoritative answer. This is the whole reason mischief
can skip the claimed state.
gogobee side
A poller modeled on pete_mischief.go: 30s interval, 20s timeout, poll errors
at Debug (a Pete predating the feature 404s here, which is not an error).
Apply through the existing equip path so reconcileMagicAttunements
(magic_items_gameplay.go) still runs — bond capacity and the cap of 3 are that
code's business, not the queue's. Short-circuit on the stamped order guid the
way placeWebMischief does.
Honest UI
An equip lands on gogobee's next poll tick, up to 30s out. The UI shows "queued" and never claims it landed. The order row's status is the truth; the page reflects the row.
Suggested order of work
- Ask 4 (items) — additive fields with real sources, no deploy-order hazard, no security surface. Smallest thing that visibly improves the page.
- Ask 3 (map) — the graph exists; the work is the one-hop cut and the rendering. Check the roster size cap.
- Ask 5 (equip) — the architectural step, but a well-trodden one now.
- Ask 1 (treasures) — gated on gogobee emitting loot at all. Pete's handler deploys first.
- Ask 2 (LLM dispatches) — last, because the prose guard is the only piece here that fails publicly if it is wrong.
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.