Files
Pete/adventure_expansion_spec.md
prosolis 1425033047 adventure: correct the spec where building it proved it wrong
Three claims in §4 were wrong, and each was only visible by reading both
repos together: equipped and inventory are disjoint sets (so the spec'd
attuned field was undefined, and the worn set it never asked for was the
actual gap); effects are modeled after all, by the engine's own summary
function; skill_source is dual-use and needs filtering, not forwarding.

Keeping the wrong text with the corrections above it — the pattern is worth
more than the fix. The spec was written a side at a time, so §§1-3 and 5
should be assumed to have the same class of error until checked.
2026-07-17 06:45:51 -07:00

511 lines
21 KiB
Markdown

# 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
**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:<id>"` 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.
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.