Files
Pete/adventure_expansion_spec.md
prosolis 5fac1630f6 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.
2026-07-17 00:48:07 -07:00

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:

  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:

{
  "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):

{
  "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 / ZoneEdgeinternal/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:

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

  • 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:

{
  "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.