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.
511 lines
21 KiB
Markdown
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.
|