Files
Pete/adventure_expansion_spec.md
prosolis dcd68ebdcd adventure: write down the text-colour lead before it evaporates
Found while fixing the night-phase contrast, not fixed there. The evidence
points two ways — no such rule in the built CSS and a phase-ignoring computed
colour, but a screenshot showing that same text rendering correctly — and the
contradiction is the first thing to resolve, because it decides whether there
is a bug here at all. Recorded as a lead rather than dressed up as a finding.

Also records how the live-page measurement misleads, since every one of those
traps cost time and would cost it again.
2026-07-17 07:43:38 -07:00

23 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

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:

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

Follow-ups, not covered by any of the five

Both Pete-only.

text-[color:var(--ink)]/NN may be a no-op — unresolved, verify first

Found while fixing the night-phase contrast (7d2d991), not fixed there. The evidence points two ways and I could not reconcile it:

  • No such rule exists in the built output.cssgrep 'text-\[color:var(--ink)\]/55' finds nothing, and all 21 var(--ink) references in the output come from hand-written component CSS, not from these classes.
  • The item description in the Worn panel computes to a fixed rgb(74,46,42) that does not change with the phase — that is dawn's --ink as a literal, from a source I never located.
  • Tailwind v3 cannot apply an opacity modifier to an arbitrary var() colour, which would explain all of the above.
  • But a night-phase screenshot showed that same text rendering as readable cream, which contradicts the computed value outright.

So: probably a pre-existing site-wide no-op, silently dropping the muted-text styling wherever it appears — who.html uses /45, /50, /55 throughout, and it is not confined to the new item panels. Treat the above as a lead, not a finding. Start by explaining the screenshot/computed contradiction — one of the two observations is measuring the wrong thing, and which one decides whether there is a bug here at all.

Measuring this in the live page is booby-trapped, and each of these cost time: reading an element's own background returns its translucent chip fill rather than its backdrop (walk up from parentElement); a page reload resets data-phase to the server-rendered value (data-phase-lock in layout.html:2 stops the clock script, not a reload); and reading the card colour and the text colour in different ticks silently mixes two phases. A screenshot was ground truth every time a computed value was not.

If it is real, the fix is the same shape as --warn: a phase variable, or a hand-written utility — not a Tailwind arbitrary-value class with an opacity modifier. See the pete_theme_contrast note for why dark: is never the answer here (darkMode is unconfigured, so it follows the OS, not Pete's phase — status.html and channel.html still have that bug).

Richer live sheet

The live sheet poll patches only HP/AC/room/supplies/threat (templates/who.html:287-290).