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:
476
adventure_expansion_spec.md
Normal file
476
adventure_expansion_spec.md
Normal 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.
|
||||||
Reference in New Issue
Block a user