Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d9cfd9f9a | ||
|
|
8a5fea78ba | ||
|
|
6c6de56539 | ||
|
|
3230939c51 | ||
|
|
4f4bd9fbc1 | ||
|
|
eeeac08db7 | ||
|
|
6219224ea9 | ||
|
|
1589c36e96 | ||
|
|
e90deda498 | ||
|
|
dcd68ebdcd | ||
|
|
7d2d9910cf | ||
|
|
1425033047 | ||
|
|
4ce025a82c | ||
|
|
cd84f64a22 | ||
|
|
5fac1630f6 | ||
|
|
cbe9e67b3e |
@@ -0,0 +1,168 @@
|
||||
# Adventure expansion — progress & handoff
|
||||
|
||||
Last updated 2026-07-17. Companion to `adventure_expansion_spec.md` (the wire
|
||||
contract). This file is the state of the work: what's done, what's next, and
|
||||
what will bite whoever picks it up.
|
||||
|
||||
---
|
||||
|
||||
## Read this first
|
||||
|
||||
1. `adventure_expansion_spec.md` — payloads, endpoints, file:line refs into both
|
||||
repos. It supersedes any older sketch of these asks.
|
||||
2. This file — where the work actually stands.
|
||||
|
||||
The gogobee repo is at `/home/reala-misaki/git/gogobee`. It runs on millenia
|
||||
(`reala@192.168.1.212`). Pete runs on `reala@www.parodia.dev` (bare `./pete` in
|
||||
a screen session, built **on the server, with cgo**).
|
||||
|
||||
---
|
||||
|
||||
## Status of the five asks
|
||||
|
||||
| # | Ask | State |
|
||||
|---|---|---|
|
||||
| — | Trophy case / timeline ("The record" / "The trail") | **Built, tests green, committed, NOT pushed, NOT deployed** |
|
||||
| — | Contract spec for the five | **Written** (`adventure_expansion_spec.md`) |
|
||||
| 4 | Richer item view | **Next up.** Spec'd. Partly buildable — see below |
|
||||
| 3 | Room graph → dungeon map + fog of war | Spec'd. Graph already exists in gogobee |
|
||||
| 5 | `equip_orders` queue | Spec'd. Copies mischief |
|
||||
| 1 | `treasure_found` → treasures | Spec'd, but **blocked on a gogobee feature** — no loot fact exists |
|
||||
| 2 | LLM-authored dispatches | Spec'd. **Do last** — inverts a security guarantee |
|
||||
|
||||
Build order and the reasoning behind it are in the spec's last section.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Unpushed and undeployed
|
||||
|
||||
Pete has **two unpushed commits on `main`**:
|
||||
|
||||
- `cbe9e67` — adventure: keep the facts, not just the sentence we made of them
|
||||
(the trophy case / timeline feature — `adventure_events` table,
|
||||
`internal/storage/adventure.go`, the who-page sections)
|
||||
- `5fac163` — adventure: pin the gogobee contract before Pete assumes it
|
||||
(the spec)
|
||||
|
||||
**Nothing is deployed.** The record/trail sections are live nowhere.
|
||||
|
||||
This is not a neutral delay. **Trophies are only countable going forward** —
|
||||
they're built from `adventure_events`, which only starts filling once the
|
||||
feature is deployed. Every fact gogobee emits between now and the deploy is
|
||||
counted by nobody and cannot be counted back out later (the past is prose in the
|
||||
story feed). The longer it sits, the more of every adventurer's history is
|
||||
permanently uncountable. Deploy it soon, independent of where the expansion goes.
|
||||
|
||||
gogobee's tree has **unrelated uncommitted postgame-zone work** (combat engine,
|
||||
expedition sim, plan docs). It's mid-flight and not part of this project — leave
|
||||
it alone, and keep adventure-expansion edits in separate files and commits.
|
||||
|
||||
---
|
||||
|
||||
## Next: ask 4, richer items
|
||||
|
||||
Chosen because it's additive-only, has real sources, carries no deploy-order
|
||||
hazard and no security surface, and is the smallest change that visibly improves
|
||||
the who page.
|
||||
|
||||
**gogobee:** `itemViews` (`internal/plugin/pete_roster.go:210-225`) currently
|
||||
sends five of `AdvItem`'s fields and drops two that already exist:
|
||||
|
||||
- `Slot` (`EquipmentSlot`, non-empty for MasterworkGear)
|
||||
- `SkillSource` (string, non-empty for MasterworkGear)
|
||||
|
||||
Also worth sending, from adjacent structs:
|
||||
|
||||
- `Desc` — resolve **at the push site** from `MagicItem.Desc`
|
||||
(`magic_items.go:35-46`) or `EquipmentDef.Description`
|
||||
(`adventure_character.go:172-177`). `AdvItem` rows carry no description of
|
||||
their own, which is why this is a push-site join and not a field copy.
|
||||
- `Attunement` (does it need a bond) and `Attuned` (does it have one). Distinct,
|
||||
and both matter to a player deciding what to wear — the bond cap is 3.
|
||||
|
||||
**Pete:** extend `ItemView` (`internal/storage/detail.go:23-29`) to match
|
||||
`peteclient/client.go:356-362`, then surface it on the who page.
|
||||
|
||||
Rides the private `/api/ingest/detail` push, so **either side can deploy first**.
|
||||
|
||||
**Not buildable — do not spec it in:** stat modifiers and requirements are *not
|
||||
modeled anywhere*, not merely unsent. No attack/AC/ability deltas exist on
|
||||
`AdvItem` or `MagicItem`; effects derive from Tier/Slot/SkillSource in
|
||||
`combat_stats.go:36` and `combat_bridge.go:396,489`. "+2 to hit" needs a gogobee
|
||||
engine change first. Deriving a display-only approximation from Tier/Slot at the
|
||||
push site is a lie the first time the engine and the display disagree.
|
||||
|
||||
---
|
||||
|
||||
## Landmines
|
||||
|
||||
Things that cost money to learn, or that will cost money if forgotten.
|
||||
|
||||
**Deploy order is a data-loss rule, not a preference.** An unknown `event_type`
|
||||
is a 400 on Pete; gogobee retries with backoff to `maxAttempts=8` and then
|
||||
**parks the bulletin forever** (`peteclient/client.go:79-86`,
|
||||
`plugin/pete.go:279-281`). Pete's handler ships *before* gogobee emits
|
||||
`treasure_found`, or the first treasures are gone permanently. Additive *fields*
|
||||
on existing event types are safe in either order.
|
||||
|
||||
**A limit on the fetch truncates a tally, not a list.** `EventsBySubject(name, 0)`
|
||||
means unlimited, and anything that *counts* must pass 0 and cap in the caller
|
||||
(`storage/adventure.go:108-114`). A capped read would freeze a veteran's kill
|
||||
count at 40 forever, reading as a fact rather than a missing page.
|
||||
|
||||
**`INSERT OR IGNORE` on the guid is load-bearing**, not defensive habit
|
||||
(`storage/adventure.go:45-58`). gogobee retries facts whose ack it lost. This is
|
||||
the only adventure store where a duplicate is *permanently* wrong — the roster
|
||||
forgives one by replacing itself; a double-counted boss kill is in the tally
|
||||
forever.
|
||||
|
||||
**Only `subject` earns a trophy, never `opponent`.** A duel Josie *lost* still
|
||||
names her (as the opponent in the winner's dispatch). It belongs on her trail,
|
||||
but crediting it would score a loss as a win. Pinned by
|
||||
`TestTrophyCaseIgnoresOpponentCredit`.
|
||||
|
||||
**Events key on character name**, not roster token — that's what a fact carries;
|
||||
gogobee puts no stable character id on the wire. A rename takes the history with
|
||||
it.
|
||||
|
||||
**Snapshots are dropped on failure, never queued** (`peteclient/client.go:320-327`).
|
||||
A retried snapshot is a lie about a moment that has passed, and the silence is
|
||||
what makes Pete's 12-minute staleness timer honest. Asks 3 and 4 ride snapshots:
|
||||
a dropped push means a stale map, not a wrong one.
|
||||
|
||||
**The who page's tests render the real template**, so a field slip 500s in the
|
||||
suite — but only on paths the test data reaches. `HasHistory` false skips the
|
||||
whole history block, which is why `TestWhoHistoryPanels` seeds facts through the
|
||||
real ingest handler.
|
||||
|
||||
**`/api/mischief/claim` is misnamed.** It's a *verdict* endpoint; mischief has no
|
||||
`claimed` state (`storage/mischief.go:37-42`). Don't repeat the name in
|
||||
`equip_orders` — the spec calls it `/api/equip/verdict`.
|
||||
|
||||
**The LLM dispatch guard inversion (ask 2).** `factGuard` only checks the
|
||||
*structured* `Subject`/`Opponent` fields, which is safe today only because Pete's
|
||||
templates can print nothing Pete didn't interpolate. The moment gogobee's LLM
|
||||
authors the prose, the guard is checking fields that are **no longer the thing
|
||||
being rendered**, and character names are player-chosen. A prose-level guard is
|
||||
required *in the same change* that accepts `headline`/`lede`, not as a
|
||||
follow-up. **Templates stop being the renderer and become the safety net — do not
|
||||
delete them.**
|
||||
|
||||
---
|
||||
|
||||
## Decisions already made (don't relitigate)
|
||||
|
||||
- **Room graph, not coordinates or a bare trail.** Rooms with exits, a real
|
||||
graph. It already exists in gogobee (`zone_graph.go`); the wire throws it away.
|
||||
- **Fog of war is a server-side cut**, not a CSS style. Send visited nodes plus a
|
||||
one-hop ring with `kind: "unknown"`. The map is a public page; "view source to
|
||||
find the boss room" is not fog of war.
|
||||
- **`equip_orders` copies mischief, not escrow.** An equip is a desired end state,
|
||||
not a delta, so a replay converges — mischief's precondition exactly. No
|
||||
`claimed` state, no stale-reoffer window; the poll loop is its own retry.
|
||||
- **Equip UI says "queued", never claims it landed.** It lands on gogobee's next
|
||||
poll tick, up to 30s out. The order row's status is the truth.
|
||||
- **"Treasures found" won't be faked from vault contents.** A *bought* sword is
|
||||
not a trophy. Needs a real `treasure_found` fact.
|
||||
- **Blank state over a wall of zeroes.** Adventurers predating `adventure_events`
|
||||
render no record section at all. A clean absence, deliberate.
|
||||
@@ -0,0 +1,732 @@
|
||||
# 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`)
|
||||
|
||||
**SHIPPED 2026-07-17: Pete `eeeac08`, gogobee `22b7949`. All tests green both
|
||||
sides, gofmt-clean, screenshot-verified (realistic + max-length prose both sit
|
||||
cleanly on the card), NOT deployed.** Built as written below (additive
|
||||
`headline`/`lede`, prose-guard, template fallback), with one architecture
|
||||
decision the spec did not surface:
|
||||
|
||||
> **This section contradicts `pete_adventure_news_voice.md`**, the older
|
||||
> foundational doc, which says *Pete* owns the voice and gogobee is "compute,
|
||||
> not ghostwriter" — the flow there is Pete builds a voiced prompt and calls a
|
||||
> generic gogobee inference endpoint. That needs a **Pete→gogobee route**, which
|
||||
> `roster.go:23-25` forbids ("no route back into the game box's network"). The
|
||||
> network constraint kills the voice-doc design, so §2's gogobee-authors-and-
|
||||
> pushes model is the only one that fits one-way delivery. Confirmed with the
|
||||
> owner before building. Cost: the warm-reporter voice now lives in gogobee's
|
||||
> prompt (`pete_dispatch_voice.go`), softening "gogobee never sees Pete" — a
|
||||
> deliberate, owner-approved trade, not an oversight.
|
||||
|
||||
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.
|
||||
|
||||
## 6. Item comparison — "is this backpack item an upgrade?" (SHIPPED 2026-07-17)
|
||||
|
||||
**SHIPPED 2026-07-17 (session 5):** gogobee `7e59697`, Pete `6c6de56`. All tests
|
||||
green both sides, gofmt-clean, screenshot-verified day + night (all six verdict
|
||||
chips, including the purple `new` chip the theme-contrast history warned about).
|
||||
NOT deployed. Additive private `Compare` field → safe deploy order either way.
|
||||
|
||||
**The one both-sides error, and it overturned a settled design decision: the game
|
||||
only ever wears one ring.** `DnDSlotRing2` is declared and looped over as a valid
|
||||
slot, but nothing in live code assigns to it — every ring in the registry is
|
||||
`Slot: ring_1`, and the only equip path (`applyMagicEquip`, the same one ask 5's
|
||||
web button drives) equips to `mi.Slot`. So "weaker of the two worn rings" (the
|
||||
verified-at-scope proposal below) describes a trade that can't happen. The shipped
|
||||
behaviour: a backpack ring compares against the `ring_1` occupant — which collapses
|
||||
the ring special-case entirely, since **every** item's compare target is just
|
||||
`mi.Slot`. Even a section written after verifying both repos carried the class of
|
||||
error the rest of the spec did; the ring-slot subtlety was flagged "confirm before
|
||||
building" and the confirmation is what caught it. (Owner was asked; chose ring_1.)
|
||||
|
||||
Everything else below shipped as written: strict-dominance verdict, engine-computed
|
||||
deltas over tempered effects (reusing `magicItemEffectFor`, no Pete-side math), the
|
||||
inert override that counts bonds *after* the slot's occupant is evicted, always-
|
||||
visible verdict chip (no hover — phones don't have one) with deltas beside it. Chip
|
||||
colours mix a fixed hue into `--ink`/`--card` so they survive all four phases by
|
||||
construction (the map's trick), NOT a `dark:` variant. The mobile hover→tap
|
||||
disclosure the spec proposed was dropped: deltas are short (≤3 chips), so showing
|
||||
them inline is simpler and needs no JS.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
A sixth ask, scoped after all five shipped. On the owner's own page, hovering a
|
||||
backpack magic item shows a **compare card**: the item currently worn in that
|
||||
same slot and the per-stat deltas, so the player can answer "is this better than
|
||||
what I've got on?" without scrolling between two panels and eyeballing two
|
||||
opaque effect strings.
|
||||
|
||||
**This section was written AFTER verifying both repos** (unlike §§1-5, which were
|
||||
written a side at a time and were wrong wherever the two sides disagreed). The
|
||||
findings below are checked against the gogobee engine, not assumed — but treat
|
||||
the *design decisions* (verdict semantics, ring handling) as proposals, not
|
||||
settled, and re-confirm the file refs at build time.
|
||||
|
||||
### The load-bearing constraint (same one as §4)
|
||||
|
||||
**Pete must not compute the comparison.** The ask-4 lesson: a display-only power
|
||||
approximation *lies the moment it disagrees with the engine*, and character math
|
||||
lives in gogobee. That is doubly true here — the comparison depends on three
|
||||
things Pete does not hold:
|
||||
|
||||
- **Tempering.** An item's effective power folds in its per-instance temper
|
||||
(`EquippedMagicItem.Effective()` → `temperedItem`, `magic_items_gameplay.go:252`).
|
||||
The worn item and the backpack item each temper differently; the diff must be
|
||||
over *tempered* effects.
|
||||
- **Bond availability.** An attunement item worn with no free bond is **inert** —
|
||||
equipping it changes nothing. So the honest verdict for such an item is "would
|
||||
sit inert until you free a bond," not "downgrade." Same distinction §4/ask 4
|
||||
drew, resurfacing on the compare card. Bond state is engine-side.
|
||||
- **Which slot it lands in** (rings — see below).
|
||||
|
||||
So gogobee computes the comparison and pushes it; Pete renders it. **Simple diff
|
||||
arithmetic, engine-side inputs.**
|
||||
|
||||
### What the comparison is built from — this is the good news
|
||||
|
||||
The Worn-panel items are **magic items**, and their power is a *structured
|
||||
numeric struct*, not just the opaque `Effect` string:
|
||||
`magicItemEffect{DamageBonus float64, DamageReductMult float64, FlatDmgStart int,
|
||||
InitiativeBias float64, MaxHP int}` (`magic_items_gameplay.go:179`), derived by
|
||||
`magicItemEffectFor(mi)` (`:212`) keyed on Kind+Rarity with a per-item overlay.
|
||||
So a real field-by-field diff exists. **Diff the structs, never parse the
|
||||
`Effect` summary string** — the summary drops any field that is zero, so parsing
|
||||
it back loses deltas.
|
||||
|
||||
Direction of "better" per field (the verdict logic needs this):
|
||||
`DamageBonus` ↑, `FlatDmgStart` ↑, `InitiativeBias` ↑, `MaxHP` ↑ are gains;
|
||||
`DamageReductMult` is a multiplier on damage taken in `(0,1]`, so **lower is
|
||||
better** (0.90 = −10% damage taken).
|
||||
|
||||
### NOT the masterwork gear
|
||||
|
||||
There are two equip systems. This ask is the **magic-item** Worn/backpack panels
|
||||
(`MagicItem` / `DnDSlot`, effects via `magicItemEffectFor`). The other is
|
||||
masterwork `EquipmentSlot`/`AdvEquipment` gear feeding `DerivePlayerStats` →
|
||||
`CombatStats` (`combat_stats.go`). Do **not** cross the streams — different
|
||||
slots, different power model, scoped out in ask 5. A compare only ever pairs a
|
||||
magic item against a magic item.
|
||||
|
||||
### Payload
|
||||
|
||||
Additive `Compare` sub-object on the backpack `ItemView` (`peteclient.ItemView`,
|
||||
mirrored in Pete `storage.ItemView`). Owner-private — it rides the PlayerDetail
|
||||
`detail_json` blob (**no migration**, no new endpoint, no public exposure), same
|
||||
channel as §4. `omitempty` → safe deploy order either way. Item names are
|
||||
game-authored (not player names), so **no prose-guard / injection surface** like
|
||||
§2.
|
||||
|
||||
```json
|
||||
"compare": {
|
||||
"verdict": "upgrade | downgrade | sidegrade | same | new | inert",
|
||||
"vs_name": "Ring of Protection", // worn item being replaced; "" when verdict=new
|
||||
"vs_slot": "ring_2", // the slot it would land in (see rings)
|
||||
"deltas": [
|
||||
{"label": "damage", "better": true, "text": "+3% damage"},
|
||||
{"label": "hp", "better": false, "text": "-4 HP"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`deltas` is engine-rendered player-facing text (like `magicItemEffectSummary`),
|
||||
one entry per changed field, each flagged `better`. Pete renders chips/arrows off
|
||||
`better`; it does no math.
|
||||
|
||||
### Verdict semantics (proposal — confirm)
|
||||
|
||||
Different stats are **not fungible** — the engine cannot say +3% damage beats
|
||||
−4 HP. So use **strict dominance**, and let the player judge the mixed case:
|
||||
|
||||
- **upgrade** — every delta a gain (≥), at least one strict.
|
||||
- **downgrade** — every delta a loss (≤), at least one strict.
|
||||
- **sidegrade** — mixed. *This is the case the string-only view could never
|
||||
show, and the reason this ask exists.* Show all deltas; claim no winner.
|
||||
- **same** — no field differs.
|
||||
- **new** — the target slot is empty; frame as "equips into an empty <slot>,"
|
||||
all-gain but labelled a fill, not a replacement.
|
||||
- **inert** — attunement item, no free bond: overrides the stat verdict, because
|
||||
wearing it does nothing until a bond frees.
|
||||
|
||||
### Rings are double-slot (the subtlety to decide)
|
||||
|
||||
`DnDSlotRing1` / `DnDSlotRing2` (`dnd_equipment.go:24-25`) — two ring slots. A
|
||||
backpack ring has to compare against *something*. Proposal: compare against the
|
||||
**weaker of the two worn rings** (the one a sensible player would replace); if
|
||||
either ring slot is empty, verdict `new` (fills the empty slot). Flag: this is a
|
||||
judgement call, not derivable — confirm before building.
|
||||
|
||||
### Integration points
|
||||
|
||||
- **gogobee:** `itemViews` (`pete_roster.go:219`) builds the backpack ItemViews
|
||||
but does **not** currently know the equipped set (it is shared with the vault
|
||||
call). The compare needs the equipped magic items in scope — pass them into a
|
||||
backpack-only post-pass, or a new `itemViewsWithCompare(inv, equipped)`. Attach
|
||||
`Compare` for backpack magic items only (vault rows carry an id today but no
|
||||
button; compare follows the same "backpack only" rule). Reuse
|
||||
`magicItemEffectFor` on `temperedItem(...)` for both sides; read bonds from
|
||||
`loadEquippedMagicItems`/the attune cap.
|
||||
- **Pete:** `itemRow` (`internal/web/who.go:111`) already carries per-panel state;
|
||||
add the compare card to the backpack row template in `who.html`. Hover-tooltip
|
||||
on desktop. **Mobile has no hover** — render an always-visible verdict chip
|
||||
(⬆ upgrade / ⬇ downgrade / ⇄ sidegrade / ✦ new / ⚠ inert) and put the full
|
||||
deltas behind a tap. New Tailwind classes → rebuild+commit `output.css` (the
|
||||
committed-artifact trap from every prior ask). Screenshot-verify day + night.
|
||||
|
||||
### Cost / honest scope
|
||||
|
||||
The engine work is small (one diff function over an existing struct, plus the
|
||||
ring/bond decisions). The UI is the bulk: a tooltip that also degrades to a
|
||||
tap-target on touch, styled for both phases. No new table, no migration, no new
|
||||
endpoint, no public surface. Deploy order free (additive private field).
|
||||
|
||||
---
|
||||
|
||||
## 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.css` — `grep '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`).
|
||||
@@ -0,0 +1,276 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The durable record of what has actually happened in the realm.
|
||||
//
|
||||
// Every other adventure table is a snapshot gogobee replaces wholesale. This one
|
||||
// accumulates, because the questions it answers are historical: what has this
|
||||
// adventurer killed, where have they been, how many times have they died. The
|
||||
// story feed technically holds the same information — but as English, inside a
|
||||
// headline, which you cannot count.
|
||||
//
|
||||
// Pete still computes nothing about the *game*. It only counts facts gogobee
|
||||
// already told it. No row here is ever authored by Pete or edited after insert.
|
||||
|
||||
// AdvEvent is one game fact, kept as fact rather than as the sentence it was
|
||||
// rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push,
|
||||
// class_race) that describe delivery rather than the event. Stakes is the one
|
||||
// former transport field kept here: for a treasure_found it carries the item's
|
||||
// name, which is the fact, not the delivery.
|
||||
type AdvEvent struct {
|
||||
GUID string `json:"guid"`
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"`
|
||||
Subject string `json:"subject"`
|
||||
Opponent string `json:"opponent"`
|
||||
Boss string `json:"boss"`
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Level int `json:"level"`
|
||||
Tally int `json:"tally"`
|
||||
Outcome string `json:"outcome"`
|
||||
Milestone string `json:"milestone"`
|
||||
Stakes string `json:"stakes"`
|
||||
Actors []string `json:"actors"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
|
||||
// gogobee retries a fact whose ack it lost, and a retried siege must not add a
|
||||
// second kill to anybody's tally. The story insert upstream is guarded by
|
||||
// IsGUIDSeen for the same reason; this is the same guarantee enforced by the
|
||||
// table rather than by a check-then-act that a concurrent retry could race.
|
||||
func InsertAdventureEvent(e *AdvEvent) error {
|
||||
actors, err := json.Marshal(e.Actors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = Get().Exec(`
|
||||
INSERT OR IGNORE INTO adventure_events
|
||||
(guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, actors, occurred_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
|
||||
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// BossTally is one monster and how many times this adventurer has put it down.
|
||||
type BossTally struct {
|
||||
Boss string `json:"boss"`
|
||||
Kills int `json:"kills"`
|
||||
First bool `json:"first"` // they were the first in the realm to ever clear it
|
||||
}
|
||||
|
||||
// ZoneTally is one zone and how many times this adventurer has cleared it.
|
||||
type ZoneTally struct {
|
||||
Zone string `json:"zone"`
|
||||
Region string `json:"region"`
|
||||
Clears int `json:"clears"`
|
||||
First bool `json:"first"`
|
||||
}
|
||||
|
||||
// TrophyCase is an adventurer's whole history, counted.
|
||||
//
|
||||
// Treasure is counted only from the treasure_found fact, never from the vault
|
||||
// snapshot: a bought sword is not a trophy, and the snapshot is current-state
|
||||
// with no "found it in X on day 3". So a treasure tally that predates the first
|
||||
// treasure_found is a clean zero, not a back-derivation.
|
||||
type TrophyCase struct {
|
||||
Name string `json:"name"`
|
||||
Bosses []BossTally `json:"bosses,omitempty"`
|
||||
Zones []ZoneTally `json:"zones,omitempty"`
|
||||
Treasures []TreasureTally `json:"treasures,omitempty"`
|
||||
BossKills int `json:"boss_kills"` // total, including firsts
|
||||
BossFirsts int `json:"boss_firsts"` // realm-firsts among them
|
||||
ZoneClears int `json:"zone_clears"`
|
||||
ZoneFirsts int `json:"zone_firsts"`
|
||||
TreasuresFound int `json:"treasures_found"` // story-grade finds, total
|
||||
TreasureFirsts int `json:"treasure_firsts"` // realm-first hoards among them
|
||||
Deaths int `json:"deaths"`
|
||||
Retreats int `json:"retreats"`
|
||||
RivalWins int `json:"rival_wins"`
|
||||
Milestones []string `json:"milestones,omitempty"`
|
||||
Survived int `json:"survived"` // mischief contracts walked away from
|
||||
Downed int `json:"downed"` // mischief contracts that landed
|
||||
Events int `json:"events"` // total facts on file
|
||||
FirstSeen int64 `json:"first_seen"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
// TreasureTally is one story-grade find: the item's name, the zone it came out
|
||||
// of, and whether it was a realm-first hoard. Unlike bosses and zones there is no
|
||||
// repeat count — a named treasure is found once, so each is its own row.
|
||||
type TreasureTally struct {
|
||||
Item string `json:"item"`
|
||||
Zone string `json:"zone"`
|
||||
First bool `json:"first"` // first in the realm to pull this hoard
|
||||
}
|
||||
|
||||
// EventsBySubject returns every fact about a character, newest first. This is the
|
||||
// timeline *and* the trophy source: one read, aggregated in Go, because the pool
|
||||
// is MaxOpenConns(1) and six COUNT queries against it would serialize behind each
|
||||
// other for an answer that fits comfortably in memory (a busy adventurer
|
||||
// accumulates tens of facts, not millions).
|
||||
//
|
||||
// limit <= 0 means no limit, and callers who count should use it. A limit here
|
||||
// silently truncates a *tally*, not just a list: read 40 facts for a display cap
|
||||
// and a veteran's kill count quietly stops at 40 and stays wrong forever. Cap the
|
||||
// trail in the caller, after the counting is done.
|
||||
//
|
||||
// Facts where the character is the *opponent* are included: a duel they lost is
|
||||
// part of their history, and only the subject field would otherwise carry it.
|
||||
func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
q := `
|
||||
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||
level, tally, outcome, milestone, stakes, actors, occurred_at
|
||||
FROM adventure_events
|
||||
WHERE subject = ? OR opponent = ?
|
||||
ORDER BY occurred_at DESC`
|
||||
args := []any{name, name}
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := Get().Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AdvEvent
|
||||
for rows.Next() {
|
||||
var e AdvEvent
|
||||
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
|
||||
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||
&stakes, &actors, &e.OccurredAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||
if actors.String != "" {
|
||||
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BuildTrophyCase counts a character's history out of their facts. Pure: it takes
|
||||
// the rows EventsBySubject already read rather than querying again, so the who
|
||||
// page pays for one database read and gets both the timeline and the trophies.
|
||||
//
|
||||
// Only facts where the character is the *subject* count toward trophies. They
|
||||
// appear in an ally's dispatch as an opponent too, and crediting those would let
|
||||
// a duel someone lost show up as a kill in their own case.
|
||||
func BuildTrophyCase(name string, events []AdvEvent) TrophyCase {
|
||||
tc := TrophyCase{Name: name}
|
||||
bosses := map[string]*BossTally{}
|
||||
zones := map[string]*ZoneTally{}
|
||||
|
||||
for _, e := range events {
|
||||
if tc.LastSeen == 0 || e.OccurredAt > tc.LastSeen {
|
||||
tc.LastSeen = e.OccurredAt
|
||||
}
|
||||
if tc.FirstSeen == 0 || e.OccurredAt < tc.FirstSeen {
|
||||
tc.FirstSeen = e.OccurredAt
|
||||
}
|
||||
tc.Events++
|
||||
|
||||
if e.Subject != name {
|
||||
continue // they're the other party in someone else's dispatch
|
||||
}
|
||||
switch e.EventType {
|
||||
case "boss_kill", "boss_first":
|
||||
tc.BossKills++
|
||||
if e.Boss != "" {
|
||||
b := bosses[e.Boss]
|
||||
if b == nil {
|
||||
b = &BossTally{Boss: e.Boss}
|
||||
bosses[e.Boss] = b
|
||||
}
|
||||
b.Kills++
|
||||
if e.EventType == "boss_first" {
|
||||
b.First = true
|
||||
}
|
||||
}
|
||||
if e.EventType == "boss_first" {
|
||||
tc.BossFirsts++
|
||||
}
|
||||
case "zone_clear", "zone_first":
|
||||
tc.ZoneClears++
|
||||
if e.Zone != "" {
|
||||
z := zones[e.Zone]
|
||||
if z == nil {
|
||||
z = &ZoneTally{Zone: e.Zone, Region: e.Region}
|
||||
zones[e.Zone] = z
|
||||
}
|
||||
z.Clears++
|
||||
if e.EventType == "zone_first" {
|
||||
z.First = true
|
||||
}
|
||||
}
|
||||
if e.EventType == "zone_first" {
|
||||
tc.ZoneFirsts++
|
||||
}
|
||||
case "treasure_found":
|
||||
tc.TreasuresFound++
|
||||
// A realm-first hoard rides the priority tier, the same way zone_first
|
||||
// does; a plain story-grade find is a bulletin.
|
||||
first := e.Tier == "priority"
|
||||
if first {
|
||||
tc.TreasureFirsts++
|
||||
}
|
||||
if e.Stakes != "" {
|
||||
tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first})
|
||||
}
|
||||
case "death":
|
||||
tc.Deaths++
|
||||
case "retreat":
|
||||
tc.Retreats++
|
||||
case "rival_result":
|
||||
tc.RivalWins++ // the fact's subject is the winner; see renderAdventure
|
||||
case "milestone":
|
||||
if e.Milestone != "" {
|
||||
tc.Milestones = append(tc.Milestones, e.Milestone)
|
||||
}
|
||||
case "mischief_survived":
|
||||
tc.Survived++
|
||||
case "mischief_downed":
|
||||
tc.Downed++
|
||||
}
|
||||
}
|
||||
|
||||
for _, b := range bosses {
|
||||
tc.Bosses = append(tc.Bosses, *b)
|
||||
}
|
||||
for _, z := range zones {
|
||||
tc.Zones = append(tc.Zones, *z)
|
||||
}
|
||||
// Deterministic order: most-fought first, then alphabetical. The tiebreak is
|
||||
// not cosmetic — map iteration is randomized in Go, so without it the panel
|
||||
// reshuffles on every request and a cached page and a live one disagree.
|
||||
sort.Slice(tc.Bosses, func(i, j int) bool {
|
||||
if tc.Bosses[i].Kills != tc.Bosses[j].Kills {
|
||||
return tc.Bosses[i].Kills > tc.Bosses[j].Kills
|
||||
}
|
||||
return tc.Bosses[i].Boss < tc.Bosses[j].Boss
|
||||
})
|
||||
sort.Slice(tc.Zones, func(i, j int) bool {
|
||||
if tc.Zones[i].Clears != tc.Zones[j].Clears {
|
||||
return tc.Zones[i].Clears > tc.Zones[j].Clears
|
||||
}
|
||||
return tc.Zones[i].Zone < tc.Zones[j].Zone
|
||||
})
|
||||
return tc
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// adventure_events is the only adventure store that accumulates, so it is the
|
||||
// only one where a duplicate delivery is permanently wrong: the roster forgives
|
||||
// a double push by replacing itself, a double-counted boss kill is in the tally
|
||||
// forever. These tests pin that, and pin what the trophy case will and won't
|
||||
// credit an adventurer for.
|
||||
|
||||
func ev(guid, typ, subject string, at int64) *AdvEvent {
|
||||
return &AdvEvent{GUID: guid, EventType: typ, Subject: subject, OccurredAt: at}
|
||||
}
|
||||
|
||||
// TestInsertAdventureEventIdempotent: gogobee retries a fact whose ack it lost.
|
||||
// The retry must be a no-op, not a second kill.
|
||||
func TestInsertAdventureEventIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
e := ev("boss_kill:abc:1", "boss_kill", "Josie", 1000)
|
||||
e.Boss = "The Rotmother"
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := InsertAdventureEvent(e); err != nil {
|
||||
t.Fatalf("insert %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
events, err := EventsBySubject("Josie", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("EventsBySubject: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("after 3 deliveries of one fact: got %d rows, want 1", len(events))
|
||||
}
|
||||
if tc := BuildTrophyCase("Josie", events); tc.BossKills != 1 {
|
||||
t.Fatalf("boss kills: got %d, want 1 — a retry inflated the tally", tc.BossKills)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrophyCaseCounts walks a full history and checks each tally.
|
||||
func TestTrophyCaseCounts(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
first := ev("boss_first:1:1", "boss_first", "Josie", 100)
|
||||
first.Boss = "The Rotmother"
|
||||
repeat := ev("boss_kill:2:2", "boss_kill", "Josie", 200)
|
||||
repeat.Boss = "The Rotmother"
|
||||
other := ev("boss_kill:3:3", "boss_kill", "Josie", 300)
|
||||
other.Boss = "Gravebloom"
|
||||
zone := ev("zone_clear:4:4", "zone_clear", "Josie", 400)
|
||||
zone.Zone, zone.Region = "holymachina", "the Reach"
|
||||
death := ev("death:5:5", "death", "Josie", 500)
|
||||
mile := ev("milestone:6:6", "milestone", "Josie", 600)
|
||||
mile.Milestone = "level 20"
|
||||
|
||||
for _, e := range []*AdvEvent{first, repeat, other, zone, death, mile} {
|
||||
if err := InsertAdventureEvent(e); err != nil {
|
||||
t.Fatalf("insert %s: %v", e.GUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := EventsBySubject("Josie", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("EventsBySubject: %v", err)
|
||||
}
|
||||
tc := BuildTrophyCase("Josie", events)
|
||||
|
||||
if tc.BossKills != 3 {
|
||||
t.Errorf("boss kills: got %d, want 3 (a first is still a kill)", tc.BossKills)
|
||||
}
|
||||
if tc.BossFirsts != 1 {
|
||||
t.Errorf("boss firsts: got %d, want 1", tc.BossFirsts)
|
||||
}
|
||||
if tc.ZoneClears != 1 || tc.Deaths != 1 {
|
||||
t.Errorf("zones/deaths: got %d/%d, want 1/1", tc.ZoneClears, tc.Deaths)
|
||||
}
|
||||
if len(tc.Milestones) != 1 || tc.Milestones[0] != "level 20" {
|
||||
t.Errorf("milestones: got %v, want [level 20]", tc.Milestones)
|
||||
}
|
||||
// Ordering is by kills desc: the twice-killed Rotmother outranks Gravebloom.
|
||||
if len(tc.Bosses) != 2 || tc.Bosses[0].Boss != "The Rotmother" || tc.Bosses[0].Kills != 2 {
|
||||
t.Fatalf("boss tallies: got %+v, want Rotmother×2 first", tc.Bosses)
|
||||
}
|
||||
if !tc.Bosses[0].First {
|
||||
t.Error("Rotmother should be flagged as a realm-first")
|
||||
}
|
||||
if tc.FirstSeen != 100 || tc.LastSeen != 600 {
|
||||
t.Errorf("span: got %d..%d, want 100..600", tc.FirstSeen, tc.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrophyCaseTreasures: story-grade finds count out of the treasure_found
|
||||
// fact, the priority tier marks a realm-first hoard, and the item name rides the
|
||||
// stakes field into a per-find row. A find with no name still counts but earns no
|
||||
// showcase row.
|
||||
func TestTrophyCaseTreasures(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
hoard := ev("treasure_found:1:1", "treasure_found", "Josie", 100)
|
||||
hoard.Tier, hoard.Stakes, hoard.Zone = "priority", "Crown of the Drowned King", "The Ossuary"
|
||||
find := ev("treasure_found:2:2", "treasure_found", "Josie", 200)
|
||||
find.Tier, find.Stakes, find.Zone = "bulletin", "Ring of Nine Sorrows", "The Sump"
|
||||
nameless := ev("treasure_found:3:3", "treasure_found", "Josie", 300)
|
||||
nameless.Tier = "bulletin" // a find gogobee sent without a stakes noun
|
||||
|
||||
for _, e := range []*AdvEvent{hoard, find, nameless} {
|
||||
if err := InsertAdventureEvent(e); err != nil {
|
||||
t.Fatalf("insert %s: %v", e.GUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := EventsBySubject("Josie", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("EventsBySubject: %v", err)
|
||||
}
|
||||
tc := BuildTrophyCase("Josie", events)
|
||||
|
||||
if tc.TreasuresFound != 3 {
|
||||
t.Errorf("treasures found: got %d, want 3 (a nameless find still happened)", tc.TreasuresFound)
|
||||
}
|
||||
if tc.TreasureFirsts != 1 {
|
||||
t.Errorf("treasure firsts: got %d, want 1 (only the priority hoard)", tc.TreasureFirsts)
|
||||
}
|
||||
// Only the two named finds earn a showcase row, newest first.
|
||||
if len(tc.Treasures) != 2 {
|
||||
t.Fatalf("treasure rows: got %d, want 2 (nameless earns no row)", len(tc.Treasures))
|
||||
}
|
||||
if tc.Treasures[0].Item != "Ring of Nine Sorrows" || tc.Treasures[0].First {
|
||||
t.Errorf("newest row wrong: %+v", tc.Treasures[0])
|
||||
}
|
||||
if tc.Treasures[1].Item != "Crown of the Drowned King" || !tc.Treasures[1].First || tc.Treasures[1].Zone != "The Ossuary" {
|
||||
t.Errorf("hoard row wrong: %+v", tc.Treasures[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrophyCaseIgnoresOpponentCredit is the one that matters for honesty. A
|
||||
// duel Josie *lost* still names her, as the opponent in Quack's dispatch. It
|
||||
// belongs on her timeline but must never be credited to her trophy case.
|
||||
func TestTrophyCaseIgnoresOpponentCredit(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
lost := ev("rival_result:1:1", "rival_result", "Quack", 100)
|
||||
lost.Opponent = "Josie" // Quack won; Josie is the one who got beaten
|
||||
won := ev("rival_result:2:2", "rival_result", "Josie", 200)
|
||||
won.Opponent = "Quack"
|
||||
|
||||
for _, e := range []*AdvEvent{lost, won} {
|
||||
if err := InsertAdventureEvent(e); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := EventsBySubject("Josie", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("EventsBySubject: %v", err)
|
||||
}
|
||||
// Both facts are hers to *see* — the loss is part of her story.
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("timeline: got %d events, want 2 (a loss is still history)", len(events))
|
||||
}
|
||||
// But only the one she won is hers to *claim*.
|
||||
if tc := BuildTrophyCase("Josie", events); tc.RivalWins != 1 {
|
||||
t.Fatalf("rival wins: got %d, want 1 — a loss was credited as a win", tc.RivalWins)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,10 @@ func runMigrations(d *sql.DB) error {
|
||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||
// gogobee build that predates the detail page.
|
||||
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||
// The noun a fact is about (a mischief bounty, a found treasure's name). Facts
|
||||
// recorded before the treasure_found event existed carry NULL, which is right:
|
||||
// they had no such noun to keep.
|
||||
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
|
||||
@@ -15,17 +15,66 @@ type PlayerDetail struct {
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item.
|
||||
// ItemView is one item in a private panel — backpack, vault, or worn.
|
||||
//
|
||||
// Desc and Effect arrive already resolved: gogobee's inventory rows carry no
|
||||
// description, and the combat delta is computed from the item rather than
|
||||
// stored. Effect is the game engine's own summary, not Pete's guess at one — if
|
||||
// it ever disagrees with what the item does in a fight, that is a gogobee bug
|
||||
// and not something Pete can paper over here.
|
||||
//
|
||||
// Attunement means the item wants a bond; Attuned means it has one. Only worn
|
||||
// items can be Attuned — equipping moves the row out of gogobee's inventory
|
||||
// table entirely, so a backpack item's bond state isn't false, it's undefined.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
// ID is the adventure_inventory row id, sent only for a backpack item that can
|
||||
// be worn through the magic-item path — so a non-zero ID doubles as "this item
|
||||
// has an Equip button." Worn items carry none: unequip keys on Slot. The id is
|
||||
// the handle an equip order round-trips back to gogobee to name the item.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
SkillSource string `json:"skill_source,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
Attunement bool `json:"attunement,omitempty"`
|
||||
Attuned bool `json:"attuned,omitempty"`
|
||||
// Compare, set only on backpack magic items (the ones carrying an equip ID),
|
||||
// pairs this item against what is worn in the slot it would equip into. gogobee
|
||||
// computes the verdict and per-stat deltas (the power math needs tempering and
|
||||
// bond state, which live in the engine); Pete only renders it. Owner-private,
|
||||
// rides detail_json — no public exposure. Item names here are game-authored, so
|
||||
// there is no injection surface like the LLM dispatch prose.
|
||||
Compare *ItemCompare `json:"compare,omitempty"`
|
||||
}
|
||||
|
||||
// ItemCompare is gogobee's verdict for equipping a backpack magic item over what
|
||||
// is currently worn in its slot. Pete renders it verbatim and does no arithmetic.
|
||||
type ItemCompare struct {
|
||||
// Verdict: upgrade, downgrade, sidegrade, same, new, or inert.
|
||||
Verdict string `json:"verdict"`
|
||||
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
|
||||
VsName string `json:"vs_name,omitempty"`
|
||||
// VsSlot is the slot the item would land in (e.g. "ring_1").
|
||||
VsSlot string `json:"vs_slot,omitempty"`
|
||||
// Deltas is one entry per changed stat, each pre-flagged better/worse.
|
||||
Deltas []ItemDelta `json:"deltas,omitempty"`
|
||||
}
|
||||
|
||||
// ItemDelta is one stat's change between the candidate and the worn item.
|
||||
type ItemDelta struct {
|
||||
Label string `json:"label"`
|
||||
Better bool `json:"better"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The equip queue: the one adventure feature that carries intent *back* to the
|
||||
// game box, and it does it the same way mischief does — no new network route.
|
||||
//
|
||||
// A signed-in owner, on their own detail page, asks to wear an item they own or
|
||||
// take one off. Pete records only the *intent*; it never touches the game's
|
||||
// equipment tables. gogobee's poll loop drains the pending orders, runs the real
|
||||
// equip through its own rules (slot eviction, the 3-bond attunement cap,
|
||||
// reconcile), and hands back a verdict Pete files against the order. The guid is
|
||||
// the idempotency key end to end.
|
||||
//
|
||||
// Unlike mischief the underlying game action is NOT naturally idempotent —
|
||||
// equipping consumes an inventory row and regenerates it on unequip, so replaying
|
||||
// the flow would double-move items. gogobee therefore short-circuits on the order
|
||||
// guid before it mutates anything (see its poller). On Pete's side the mechanic
|
||||
// is mischief's exactly: a verdict only moves a still-pending order, so a retried
|
||||
// verdict is a no-op.
|
||||
|
||||
// EquipOrder is one wear/remove request and its current standing.
|
||||
type EquipOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
OwnerSub string `json:"-"` // OIDC subject; keys "my orders", never sent to gogobee
|
||||
OwnerLocalpart string `json:"owner_localpart"` // Matrix localpart gogobee turns into an MXID — the character to dress
|
||||
CharacterName string `json:"character_name,omitempty"` // display copy, frozen at order time; gogobee ignores it
|
||||
ItemID int64 `json:"item_id,omitempty"` // adventure_inventory row id, for an equip; unused for unequip
|
||||
ItemName string `json:"item_name"` // display copy
|
||||
Slot string `json:"slot"` // the magic-item slot to fill or clear
|
||||
Action string `json:"action"` // equip / unequip
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Actions. These cross the wire to gogobee, so they are part of the contract.
|
||||
const (
|
||||
EquipActionEquip = "equip"
|
||||
EquipActionUnequip = "unequip"
|
||||
)
|
||||
|
||||
// Order states. Terminal states are enumerated, not free-text, so the page can
|
||||
// say something specific; detail carries the prose. The rejection set is honest
|
||||
// to what gogobee's equip path can actually return: it auto-evicts a slot's
|
||||
// current occupant (so there is no "slot taken") and equips over the bond cap as
|
||||
// inert rather than refusing (so there is no "requirements" bounce). What is left
|
||||
// is the item having moved out from under the order, or not being wearable.
|
||||
const (
|
||||
EquipPending = "pending" // placed; gogobee hasn't acted yet
|
||||
EquipApplied = "applied" // worn/removed; detail says how (bonded, inert, ...)
|
||||
EquipRejectedNotOwned = "rejected_not_owned" // the item is no longer in the pack (stale page)
|
||||
EquipRejectedNotWorn = "rejected_not_worn" // unequip of a slot that's already empty
|
||||
EquipRejectedNotEquipp = "rejected_not_equippable" // the item has no slot to fill
|
||||
)
|
||||
|
||||
// validEquipVerdict is the set of terminal states gogobee may hand back.
|
||||
func validEquipVerdict(status string) bool {
|
||||
switch status {
|
||||
case EquipApplied, EquipRejectedNotOwned, EquipRejectedNotWorn, EquipRejectedNotEquipp:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validEquipAction(action string) bool {
|
||||
return action == EquipActionEquip || action == EquipActionUnequip
|
||||
}
|
||||
|
||||
var ErrNoSuchEquipOrder = errors.New("equip: no such order")
|
||||
|
||||
// InsertEquipOrder records a fresh, pending order and returns it with a new guid.
|
||||
// The guid is minted here so the owner sees a stable reference the instant they
|
||||
// click, before gogobee has heard of it. The caller has already checked the owner
|
||||
// is signed in and owns the page; eligibility (still-owned, wearable, bond cap) is
|
||||
// gogobee's, at verdict time.
|
||||
func InsertEquipOrder(ownerSub, ownerLocalpart, characterName string, itemID int64, itemName, slot, action string) (EquipOrder, error) {
|
||||
if !validEquipAction(action) {
|
||||
return EquipOrder{}, fmt.Errorf("equip: bad action %q", action)
|
||||
}
|
||||
guid, err := newGUID()
|
||||
if err != nil {
|
||||
return EquipOrder{}, err
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`INSERT INTO equip_orders
|
||||
(guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guid, ownerSub, ownerLocalpart, characterName, itemID, itemName, slot, action, EquipPending, now, now,
|
||||
); err != nil {
|
||||
return EquipOrder{}, fmt.Errorf("equip: insert order: %w", err)
|
||||
}
|
||||
return EquipOrder{
|
||||
GUID: guid, OwnerSub: ownerSub, OwnerLocalpart: ownerLocalpart,
|
||||
CharacterName: characterName, ItemID: itemID, ItemName: itemName,
|
||||
Slot: slot, Action: action, Status: EquipPending, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PendingEquipOrders is gogobee's poll: every order still waiting. Like mischief
|
||||
// there is no claimed-but-stale window — a gogobee that dies mid-apply leaves the
|
||||
// order pending to be offered again, and gogobee's own guid guard makes the replay
|
||||
// a no-op.
|
||||
func PendingEquipOrders(limit int) ([]EquipOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM equip_orders
|
||||
WHERE status = ?
|
||||
ORDER BY created_at
|
||||
LIMIT ?`,
|
||||
EquipPending, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("equip: pending orders: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEquipOrders(rows)
|
||||
}
|
||||
|
||||
// ResolveEquipOrder files gogobee's verdict against a pending order. Idempotent by
|
||||
// exactly mischief's mechanic: the UPDATE only moves a still-pending row, and the
|
||||
// row is read back unconditionally so a first verdict, a retried verdict, and a
|
||||
// missing row all take one path.
|
||||
func ResolveEquipOrder(guid, status, detail string) (EquipOrder, error) {
|
||||
if !validEquipVerdict(status) {
|
||||
return EquipOrder{}, fmt.Errorf("equip: bad verdict %q", status)
|
||||
}
|
||||
now := nowUnix()
|
||||
if _, err := Get().Exec(
|
||||
`UPDATE equip_orders SET status = ?, detail = ?, updated_at = ?
|
||||
WHERE guid = ? AND status = ?`,
|
||||
status, detail, now, guid, EquipPending,
|
||||
); err != nil {
|
||||
return EquipOrder{}, fmt.Errorf("equip: resolve order: %w", err)
|
||||
}
|
||||
return EquipOrderByGUID(guid)
|
||||
}
|
||||
|
||||
// EquipOrderByGUID reads one order.
|
||||
func EquipOrderByGUID(guid string) (EquipOrder, error) {
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM equip_orders WHERE guid = ?`, guid,
|
||||
)
|
||||
if err != nil {
|
||||
return EquipOrder{}, fmt.Errorf("equip: read order: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanEquipOrders(rows)
|
||||
if err != nil {
|
||||
return EquipOrder{}, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return EquipOrder{}, ErrNoSuchEquipOrder
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// EquipOrdersByOwner returns an owner's own recent orders, newest first, for the
|
||||
// status strip on the detail page. Keyed on the OIDC subject so a username change
|
||||
// doesn't strand history.
|
||||
func EquipOrdersByOwner(ownerSub string, limit int) ([]EquipOrder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := Get().Query(
|
||||
`SELECT guid, owner_sub, owner_localpart, character_name, item_id, item_name, slot, action, status, COALESCE(detail, ''), created_at, updated_at
|
||||
FROM equip_orders
|
||||
WHERE owner_sub = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
ownerSub, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("equip: orders by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEquipOrders(rows)
|
||||
}
|
||||
|
||||
// CountEquipOrdersSince backs the web anti-spam guard — the real eligibility check
|
||||
// is gogobee's at verdict time; this only blunts a stuck mouse button.
|
||||
func CountEquipOrdersSince(ownerSub string, since int64) (int, error) {
|
||||
var n int
|
||||
err := Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM equip_orders WHERE owner_sub = ? AND created_at >= ?`,
|
||||
ownerSub, since,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("equip: count recent orders: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func scanEquipOrders(rows *sql.Rows) ([]EquipOrder, error) {
|
||||
var out []EquipOrder
|
||||
for rows.Next() {
|
||||
var o EquipOrder
|
||||
if err := rows.Scan(&o.GUID, &o.OwnerSub, &o.OwnerLocalpart, &o.CharacterName,
|
||||
&o.ItemID, &o.ItemName, &o.Slot, &o.Action, &o.Status, &o.Detail,
|
||||
&o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("equip: scan order: %w", err)
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEquipOrderLifecycle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 42, "Cloak of Elvenkind", "cloak", EquipActionEquip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != EquipPending {
|
||||
t.Fatalf("fresh order status = %q, want pending", o.Status)
|
||||
}
|
||||
if o.ItemID != 42 || o.Slot != "cloak" || o.Action != EquipActionEquip {
|
||||
t.Fatalf("order fields lost through insert: %+v", o)
|
||||
}
|
||||
|
||||
pending, err := PendingEquipOrders(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].GUID != o.GUID {
|
||||
t.Fatalf("pending = %+v, want the one order we just placed", pending)
|
||||
}
|
||||
|
||||
got, err := ResolveEquipOrder(o.GUID, EquipApplied, "worn and bonded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != EquipApplied || got.Detail != "worn and bonded" {
|
||||
t.Fatalf("resolved order = %+v, want applied with detail", got)
|
||||
}
|
||||
if pending, _ := PendingEquipOrders(10); len(pending) != 0 {
|
||||
t.Fatalf("applied order still pending: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipResolveIsIdempotent: gogobee's poll loop retries, so a verdict can
|
||||
// arrive twice — the second must not overwrite the first. This is the whole
|
||||
// reason Pete can copy mischief's mechanic even though the game action underneath
|
||||
// is not itself idempotent (gogobee guards that separately, on the order guid).
|
||||
func TestEquipResolveIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 7, "Ring of Protection", "ring_1", EquipActionEquip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveEquipOrder(o.GUID, EquipApplied, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveEquipOrder(o.GUID, EquipRejectedNotOwned, "second")
|
||||
if err != nil {
|
||||
t.Fatalf("re-resolve errored: %v", err)
|
||||
}
|
||||
if got.Status != EquipApplied || got.Detail != "first" {
|
||||
t.Fatalf("idempotency broken: order became %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipResolveUnknownAndBadVerdict(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
if _, err := ResolveEquipOrder("nope", EquipApplied, ""); !errors.Is(err, ErrNoSuchEquipOrder) {
|
||||
t.Fatalf("unknown guid err = %v, want ErrNoSuchEquipOrder", err)
|
||||
}
|
||||
|
||||
o, _ := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Boots", "feet", EquipActionEquip)
|
||||
if _, err := ResolveEquipOrder(o.GUID, "exploded", ""); err == nil {
|
||||
t.Error("a bogus verdict status was accepted")
|
||||
}
|
||||
if got, _ := EquipOrderByGUID(o.GUID); got.Status != EquipPending {
|
||||
t.Fatalf("order moved off pending on a bad verdict: %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipInsertRejectsBadAction: the action is part of the contract, so a value
|
||||
// that isn't equip/unequip must not reach the table.
|
||||
func TestEquipInsertRejectsBadAction(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if _, err := InsertEquipOrder("sub-1", "josie", "Josie", 1, "Thing", "cloak", "wield"); err == nil {
|
||||
t.Fatal("a bogus action was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipUnequipCarriesSlotNotItem: an unequip has no live inventory row to name,
|
||||
// so it rides on the slot alone — item_id 0 is expected, not a bug.
|
||||
func TestEquipUnequipCarriesSlotNotItem(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
o, err := InsertEquipOrder("sub-1", "josie", "Josie", 0, "Cloak of Elvenkind", "cloak", EquipActionUnequip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := EquipOrderByGUID(o.GUID)
|
||||
if got.Action != EquipActionUnequip || got.Slot != "cloak" || got.ItemID != 0 {
|
||||
t.Fatalf("unequip order = %+v, want slot-keyed with no item id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipOrdersByOwnerAndCount(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := InsertEquipOrder("sub-A", "alice", "Alice", int64(i+1), "Item", "cloak", EquipActionEquip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := InsertEquipOrder("sub-B", "bob", "Bob", 9, "Item", "cloak", EquipActionEquip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mine, err := EquipOrdersByOwner("sub-A", 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mine) != 3 {
|
||||
t.Fatalf("alice sees %d orders, want 3 (and none of bob's)", len(mine))
|
||||
}
|
||||
|
||||
n, err := CountEquipOrdersSince("sub-A", time.Now().Add(-time.Hour).Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("count since an hour ago = %d, want 3", n)
|
||||
}
|
||||
if n, _ := CountEquipOrdersSince("sub-A", time.Now().Add(time.Hour).Unix()); n != 0 {
|
||||
t.Fatalf("count since the future = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
|
||||
@@ -140,3 +141,31 @@ func RosterSnapshotAt() int64 {
|
||||
}
|
||||
return at.Int64
|
||||
}
|
||||
|
||||
// KnownCharacterNames returns the set of character names on the current board,
|
||||
// lowercased, for the prose-guard. It is the answer to "is this a name of a real
|
||||
// adventurer other than the one the fact is about" — a name Pete knows but that
|
||||
// the fact did not authorize walking onto a public page.
|
||||
//
|
||||
// Best-effort: an empty set (query error, or gogobee has never pushed a board)
|
||||
// disables only the name half of the guard, never the length caps. The board is
|
||||
// a snapshot, so a character who has dropped off it is not covered — the guard's
|
||||
// job is protecting people who are currently in the realm, not auditing history.
|
||||
func KnownCharacterNames() map[string]bool {
|
||||
rows, err := Get().Query(`SELECT name FROM adventure_roster WHERE name <> ''`)
|
||||
if err != nil {
|
||||
slog.Error("KnownCharacterNames query failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
names := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
names[strings.ToLower(name)] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -52,6 +52,48 @@ CREATE TABLE IF NOT EXISTS adventure_roster_meta (
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- adventure_events is the structured residue of a dispatch, and it is the one
|
||||
-- adventure table that is a *log* rather than a snapshot. That inversion is the
|
||||
-- point. The roster answers "where is Josie now" and is replaced every tick;
|
||||
-- this answers "what has Josie ever done", which no snapshot can, because each
|
||||
-- push throws the last one away.
|
||||
--
|
||||
-- It exists because the facts were already arriving and we were burning them.
|
||||
-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted
|
||||
-- them into a sentence and only the sentence was kept, so "how many bosses has
|
||||
-- she downed" was answerable only by parsing English back out of a headline.
|
||||
-- These rows are that fact, kept as fact. The story row remains the thing people
|
||||
-- read; this is the thing we can count.
|
||||
--
|
||||
-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not
|
||||
-- double-count a kill. subject/opponent are *character names*, not roster tokens,
|
||||
-- because that is what a fact carries and what the feed already prints in public.
|
||||
-- Names are therefore the join back to a board row (roster.name -> token), which
|
||||
-- is weaker than an id: a renamed or recycled character takes their history with
|
||||
-- them. gogobee doesn't put a stable character id on the wire today, and inventing
|
||||
-- one Pete-side would only be a guess at which two names were the same person.
|
||||
CREATE TABLE IF NOT EXISTS adventure_events (
|
||||
guid TEXT PRIMARY KEY, -- == stories.guid; the dispatch this fact rendered into
|
||||
event_type TEXT NOT NULL,
|
||||
tier TEXT, -- "priority" | "bulletin"
|
||||
subject TEXT, -- character name: the one it happened to
|
||||
opponent TEXT, -- character name: the other player, when there is one
|
||||
boss TEXT, -- game-authored monster name, not player-controlled
|
||||
zone TEXT,
|
||||
region TEXT,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
|
||||
outcome TEXT,
|
||||
milestone TEXT,
|
||||
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
||||
|
||||
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
|
||||
|
||||
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||
@@ -111,6 +153,43 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||
ordinal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- An equip/unequip an owner asked for from their own detail page, on its way to
|
||||
-- gogobee. This is the one adventure feature that carries intent *back* to the
|
||||
-- game box; it does it the mischief way, with no new network route — Pete records
|
||||
-- the intent, gogobee polls and applies it against its own equipment tables, and
|
||||
-- pushes back a verdict. The status ladder:
|
||||
--
|
||||
-- pending -> applied (worn or removed; detail says how)
|
||||
-- -> rejected_not_owned (the item left the pack before gogobee got here)
|
||||
-- -> rejected_not_worn (unequip of an already-empty slot)
|
||||
-- -> rejected_not_equippable (the item has no slot to fill)
|
||||
--
|
||||
-- guid is the idempotency key end to end. Note the game action is NOT naturally
|
||||
-- idempotent (equipping consumes an inventory row), so gogobee short-circuits on
|
||||
-- this guid before it mutates — unlike mischief, whose action converges on its
|
||||
-- own. owner_sub is the OIDC subject (stable across renames) and keys "my orders";
|
||||
-- owner_localpart is the Matrix localpart gogobee turns into the MXID of the
|
||||
-- character to dress. item_id is the adventure_inventory row id for an equip (the
|
||||
-- table is AUTOINCREMENT, so a stale id misses cleanly rather than hitting the
|
||||
-- wrong item); an unequip keys on slot alone. character_name and item_name are
|
||||
-- frozen display copy gogobee ignores.
|
||||
CREATE TABLE IF NOT EXISTS equip_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
owner_sub TEXT NOT NULL,
|
||||
owner_localpart TEXT NOT NULL,
|
||||
character_name TEXT NOT NULL DEFAULT '',
|
||||
item_id INTEGER NOT NULL DEFAULT 0,
|
||||
item_name TEXT NOT NULL DEFAULT '',
|
||||
slot TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL, -- equip / unequip
|
||||
status TEXT NOT NULL, -- see the ladder above
|
||||
detail TEXT, -- gogobee's human note on the verdict
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
|
||||
|
||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||
|
||||
+171
-2
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
@@ -37,6 +38,13 @@ type AdvFact struct {
|
||||
Milestone string `json:"milestone"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push"`
|
||||
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||||
// and past the prose-guard they replace the template render; otherwise Pete
|
||||
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||||
// the templates are no longer the renderer, they are the safety net. See
|
||||
// proseGuard and adventure_expansion_spec.md §2.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Lede string `json:"lede,omitempty"`
|
||||
}
|
||||
|
||||
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
|
||||
@@ -93,19 +101,36 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Render the template first. It validates the event type, and it is the
|
||||
// fallback for every fact whose LLM prose is absent or fails the guard.
|
||||
headline, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
http.Error(w, "unknown event_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
|
||||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||||
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||||
// a retried dispatch we already have a story for should cost nothing.
|
||||
if storage.IsGUIDSeen(f.GUID) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("duplicate"))
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer gogobee's LLM prose when it is present and safe. Both fields must be
|
||||
// supplied — a half-authored dispatch is not a voice, and mixing an LLM
|
||||
// headline with a template lede reads as two writers. The guard is what makes
|
||||
// the untrusted prose safe to print; a rejection is worth seeing loudly, since
|
||||
// it is either a hallucinated name or someone who found an injection path.
|
||||
if f.Headline != "" && f.Lede != "" {
|
||||
if proseGuard(f.Headline, f.Lede, f.Actors) {
|
||||
headline, lede = f.Headline, f.Lede
|
||||
} else {
|
||||
slog.Warn("adventure ingest: prose-guard rejected LLM dispatch, using template",
|
||||
"guid", f.GUID, "event_type", f.EventType)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||||
// outside every digest window. Treat "missing" as "now".
|
||||
@@ -132,6 +157,31 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Keep the fact itself, not just the sentence we made out of it. The story row
|
||||
// above is what people read; this is what the trophy case and timeline can
|
||||
// count. Best-effort on purpose: a dispatch that published is published, and
|
||||
// losing its structured twin costs a tally, not the news. Failing the request
|
||||
// here would make gogobee retry a fact we already have a story for.
|
||||
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||
GUID: f.GUID,
|
||||
EventType: f.EventType,
|
||||
Tier: f.Tier,
|
||||
Subject: f.Subject,
|
||||
Opponent: f.Opponent,
|
||||
Boss: f.Boss,
|
||||
Zone: f.Zone,
|
||||
Region: f.Region,
|
||||
Level: f.Level,
|
||||
Tally: f.Count,
|
||||
Outcome: f.Outcome,
|
||||
Milestone: f.Milestone,
|
||||
Stakes: f.Stakes,
|
||||
Actors: f.Actors,
|
||||
OccurredAt: occurredAt,
|
||||
}); err != nil {
|
||||
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
||||
}
|
||||
|
||||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||||
|
||||
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
|
||||
@@ -180,6 +230,8 @@ func advEventMeta(eventType string) (label, emoji string) {
|
||||
return "First clear", "🗺️"
|
||||
case "zone_clear":
|
||||
return "Zone cleared", "🗺️"
|
||||
case "treasure_found":
|
||||
return "Treasure", "💎"
|
||||
case "death":
|
||||
return "In memoriam", "🪦"
|
||||
case "arrival":
|
||||
@@ -347,6 +399,105 @@ func factGuard(f AdvFact) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Length caps for LLM-authored prose, enforced before render. The 64 KiB body
|
||||
// cap on the ingest request is a transport limit, not a prose limit — a
|
||||
// dispatch is a headline and a short paragraph, and anything past these is
|
||||
// malformed, not a valid long story. Over the cap falls back to the template.
|
||||
const (
|
||||
maxDispatchHeadline = 200
|
||||
maxDispatchLede = 800
|
||||
)
|
||||
|
||||
// proseGuard decides whether gogobee's LLM-authored headline+lede is safe to
|
||||
// print. factGuard checks the STRUCTURED Subject/Opponent fields; that was the
|
||||
// whole safety story while Pete's own templates were the renderer, because a
|
||||
// template can print nothing Pete did not interpolate. LLM prose breaks that
|
||||
// assumption — the guard would be validating fields that are no longer the thing
|
||||
// being rendered — so this checks the RENDERED TEXT itself.
|
||||
//
|
||||
// Two rejections, both falling back to the template:
|
||||
// - Over the length caps: a runaway or padded generation, not a dispatch.
|
||||
// - Naming a known adventurer the fact did not authorize: any character name
|
||||
// Pete holds on the current board that is absent from the fact's Actors
|
||||
// allow-list. Character names are player-chosen, so a hallucinated or
|
||||
// injected name is a live way to put words in a real person's mouth on a
|
||||
// public page. Boss/zone/region are game-authored, never on the board, so
|
||||
// they never trip this.
|
||||
//
|
||||
// The name half is best-effort: an empty board (KnownCharacterNames nil) leaves
|
||||
// only the length caps, which is the correct degraded behaviour — with no known
|
||||
// names there is nothing to impersonate that Pete could recognise anyway.
|
||||
func proseGuard(headline, lede string, actors []string) bool {
|
||||
if len(headline) > maxDispatchHeadline || len(lede) > maxDispatchLede {
|
||||
return false
|
||||
}
|
||||
allow := make(map[string]bool, len(actors))
|
||||
for _, a := range actors {
|
||||
if a != "" {
|
||||
allow[strings.ToLower(a)] = true
|
||||
}
|
||||
}
|
||||
text := strings.ToLower(headline + "\n" + lede)
|
||||
for name := range storage.KnownCharacterNames() {
|
||||
if allow[name] {
|
||||
continue
|
||||
}
|
||||
if containsWholeWord(text, name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// containsWholeWord reports whether needle appears in haystack bounded by
|
||||
// non-letter/digit runes (or the string edges). Both are already lowercased.
|
||||
// Bounding avoids a short character name ("Al") matching inside an unrelated
|
||||
// word ("Alabama") while still catching it as a standalone name; it is
|
||||
// deliberately rune-aware so a non-ASCII player name still bounds correctly,
|
||||
// where a stdlib \b would not.
|
||||
func containsWholeWord(haystack, needle string) bool {
|
||||
if needle == "" {
|
||||
return false
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
start := from + i
|
||||
end := start + len(needle)
|
||||
beforeOK := start == 0 || !isWordRune(lastRune(haystack[:start]))
|
||||
afterOK := end == len(haystack) || !isWordRune(firstRune(haystack[end:]))
|
||||
if beforeOK && afterOK {
|
||||
return true
|
||||
}
|
||||
from = start + 1
|
||||
if from >= len(haystack) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
func firstRune(s string) rune {
|
||||
for _, r := range s {
|
||||
return r
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lastRune(s string) rune {
|
||||
var last rune
|
||||
for _, r := range s {
|
||||
last = r
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// renderAdventure returns the deterministic headline + lede for a fact. Copied
|
||||
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
|
||||
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
|
||||
@@ -386,6 +537,24 @@ func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
|
||||
inRegion = " in " + f.Region
|
||||
}
|
||||
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
|
||||
case "treasure_found":
|
||||
// A story-grade find pulled from a dungeon. stakes is the item's name,
|
||||
// outcome its rarity, and the priority tier marks a realm-first hoard
|
||||
// nobody had ever pulled before — the same split zone_first uses.
|
||||
inZone := f.Zone
|
||||
if inZone == "" {
|
||||
inZone = "the dungeon"
|
||||
}
|
||||
rarity := ""
|
||||
if f.Outcome != "" {
|
||||
rarity = strings.ToLower(f.Outcome) + " "
|
||||
}
|
||||
if f.Tier == "priority" {
|
||||
return fmt.Sprintf("First ever: %s pulls %s out of %s.", f.Subject, f.Stakes, inZone),
|
||||
fmt.Sprintf("Nobody had laid hands on %s before today. %s found the %shoard deep in %s%s, first in the realm to do it. Some haul.", f.Stakes, f.Subject, rarity, inZone, atLevel), true
|
||||
}
|
||||
return fmt.Sprintf("%s turned up %s in %s.", f.Subject, f.Stakes, inZone),
|
||||
fmt.Sprintf("%s came back from %s with %s to show for it%s. A %sfind like that is worth a mention. Nice one.", f.Subject, inZone, f.Stakes, atLevel, rarity), true
|
||||
case "death":
|
||||
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
|
||||
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// TestProseAcceptedWhenClean: gogobee's LLM prose replaces the template render
|
||||
// (on the site row AND the live Matrix post) when both fields are present and
|
||||
// name nobody the fact did not authorize.
|
||||
func TestProseAcceptedWhenClean(t *testing.T) {
|
||||
const token = "t"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
const hl = "Josie went into the Ossuary alone and came back with the crown."
|
||||
const lede = "No fanfare, no party — just Josie, a locked door, and a very bad afternoon for whatever was guarding it. She walked back out at level 14 with the thing everyone else left behind."
|
||||
f := AdvFact{
|
||||
GUID: "boss_kill:j:1", EventType: "boss_kill", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||
Headline: hl, Lede: lede,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if got.Headline != hl {
|
||||
t.Errorf("stored headline = %q, want the LLM headline", got.Headline)
|
||||
}
|
||||
if got.Lede != lede {
|
||||
t.Errorf("stored lede = %q, want the LLM lede", got.Lede)
|
||||
}
|
||||
if len(*posted) != 1 || (*posted)[0].Headline != hl {
|
||||
t.Fatalf("live post did not carry LLM headline: %+v", *posted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseRejectedNamesBystander: prose that names a real adventurer on the
|
||||
// board who is NOT in the fact's Actors is the injection this guard exists for.
|
||||
// It must fall back to the template, not print the name.
|
||||
func TestProseRejectedNamesBystander(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
// Kif is a real, current adventurer — but this fact is about Josie.
|
||||
if err := storage.ReplaceRoster([]storage.RosterEntry{
|
||||
{Token: "tk", Name: "Kif", Level: 9, Status: "idle"},
|
||||
}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "boss_kill:j:2", EventType: "boss_kill", Tier: "priority",
|
||||
Actors: []string{"Josie"}, Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, OccurredAt: 1,
|
||||
Headline: "Josie and Kif split the Ossuary hoard.",
|
||||
Lede: "A tidy bit of teamwork down in the dark today.",
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
if strings.Contains(got.Headline+got.Lede, "Kif") {
|
||||
t.Errorf("bystander name leaked past the guard: %q / %q", got.Headline, got.Lede)
|
||||
}
|
||||
// The template render is what should have published instead.
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got.Headline != tHl {
|
||||
t.Errorf("did not fall back to template: headline = %q, want %q", got.Headline, tHl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseRejectedTooLong: a runaway generation past the length caps is not a
|
||||
// dispatch. Falls back to the template.
|
||||
func TestProseRejectedTooLong(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "arrival:z:1", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||
OccurredAt: 1,
|
||||
Headline: "Welcome, Zapp.",
|
||||
Lede: strings.Repeat("very long ", maxDispatchLede),
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID(f.GUID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("story not stored: %v", err)
|
||||
}
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got.Headline != tHl {
|
||||
t.Errorf("over-length prose was not rejected: headline = %q", got.Headline)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProseNeedsBothFields: a headline with no lede is half a voice. The guard
|
||||
// path is only taken when both are present; otherwise the whole template renders.
|
||||
func TestProseNeedsBothFields(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
f := AdvFact{
|
||||
GUID: "arrival:z:2", EventType: "arrival", Tier: "bulletin",
|
||||
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "human fighter",
|
||||
OccurredAt: 1,
|
||||
Headline: "Welcome, Zapp — a headline with no body.",
|
||||
// Lede intentionally empty.
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest status = %d", rw.Code)
|
||||
}
|
||||
got, _ := storage.GetStoryByGUID(f.GUID)
|
||||
tHl, _, _ := renderAdventure(f)
|
||||
if got == nil || got.Headline != tHl {
|
||||
t.Errorf("half-authored prose was used; want template headline %q", tHl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsWholeWord(t *testing.T) {
|
||||
cases := []struct {
|
||||
hay, needle string
|
||||
want bool
|
||||
}{
|
||||
{"josie and kif split it", "kif", true},
|
||||
{"the kiffish blade", "kif", false}, // substring, not a word
|
||||
{"al arrived", "al", true}, // short name, still bounded
|
||||
{"alabama arrived", "al", false}, // bounded off
|
||||
{"ended with kif.", "kif", true}, // trailing punctuation is a boundary
|
||||
{"名月 cleared it", "名月", true}, // non-ASCII name, bounded by space
|
||||
{"the 名月光 shard", "名月", false}, // non-ASCII substring, not a word
|
||||
{"nobody here", "kif", false}, // absent
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsWholeWord(c.hay, c.needle); got != c.want {
|
||||
t.Errorf("containsWholeWord(%q,%q) = %v, want %v", c.hay, c.needle, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,33 @@ func TestRenderZoneTaxonomy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTreasure: a story-grade find names the item and its zone; a
|
||||
// priority find is billed as a realm-first, and the rarity from outcome rides
|
||||
// into the lede.
|
||||
func TestRenderTreasure(t *testing.T) {
|
||||
hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
|
||||
Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
|
||||
hl, lede, ok := renderAdventure(hoard)
|
||||
if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
|
||||
t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
|
||||
}
|
||||
if !strings.Contains(lede, "legendary") {
|
||||
t.Errorf("hoard lede dropped the rarity: %q", lede)
|
||||
}
|
||||
|
||||
find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
|
||||
Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
|
||||
hl2, _, ok := renderAdventure(find)
|
||||
if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
|
||||
!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
|
||||
t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
|
||||
}
|
||||
|
||||
if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
|
||||
t.Errorf("treasure meta = %q/%q", lbl, emoji)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdventureArtAndMeta(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The equip queue's web seam.
|
||||
//
|
||||
// Two audiences, same shape as mischief. A signed-in owner, on their own detail
|
||||
// page, clicks Equip or Unequip; the OIDC-gated buy half records the intent after
|
||||
// proving they own the page and the item. gogobee hits the bearer-authed pair: it
|
||||
// polls pending orders and pushes a verdict. Pete never runs an equip rule — it
|
||||
// records intent and files the verdict; the item actually moves on the game box,
|
||||
// on gogobee's next poll tick. The UI says "queued" and never claims it landed.
|
||||
|
||||
// equipBurstWindow / equipBurstMax are Pete's own anti-spam guard, nothing more.
|
||||
// The real eligibility — still-owned, wearable, the 3-bond cap — is gogobee's, at
|
||||
// verdict time. This only stops a stuck mouse button from spooling the table.
|
||||
const (
|
||||
equipBurstWindow = time.Hour
|
||||
equipBurstMax = 40
|
||||
)
|
||||
|
||||
// equipOrderReq is the browser's request. The owner names the page they're on
|
||||
// (proving ownership), what they're doing, and the item — by its inventory row id
|
||||
// for an equip, or by slot for an unequip. Pete resolves the display facts itself
|
||||
// from the owner's own detail, never trusting the client for name or slot.
|
||||
type equipOrderReq struct {
|
||||
Token string `json:"token"`
|
||||
Action string `json:"action"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
Slot string `json:"slot"`
|
||||
}
|
||||
|
||||
// handleEquipOrder places a pending equip/unequip for the signed-in owner. It
|
||||
// asserts what Pete can honestly know: the viewer is signed in, owns this exact
|
||||
// page (proven by a row gogobee pushed, never by the token alone), and the item
|
||||
// is actually in the panel they claim. Bond caps and the rest of the rulebook are
|
||||
// gogobee's, checked when it drains the order.
|
||||
func (s *Server) handleEquipOrder(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
owner := buyerLocalpart(u)
|
||||
if owner == "" {
|
||||
writeEquipError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
|
||||
var req equipOrderReq
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeEquipError(w, http.StatusBadRequest, "no character")
|
||||
return
|
||||
}
|
||||
if req.Action != storage.EquipActionEquip && req.Action != storage.EquipActionUnequip {
|
||||
writeEquipError(w, http.StatusBadRequest, "bad action")
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership: only the localpart that owns this exact page token may dress it.
|
||||
// The detail row is gogobee's own proof of owner<->page; a token alone proves
|
||||
// nothing.
|
||||
pd, ok, err := storage.PlayerDetailByOwner(owner, req.Token)
|
||||
if err != nil {
|
||||
slog.Error("equip: owner lookup", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeEquipError(w, http.StatusForbidden, "that's not your adventurer")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the item from the owner's own panels — never from the client — so a
|
||||
// forged name or slot can't ride into the order. An equip names a backpack item
|
||||
// by its row id (present only on wearable magic items); an unequip names a worn
|
||||
// slot.
|
||||
var itemName, slot string
|
||||
if req.Action == storage.EquipActionEquip {
|
||||
it, found := findBackpackItem(pd.Inventory, req.ItemID)
|
||||
if !found {
|
||||
writeEquipError(w, http.StatusBadRequest, "that item isn't in your pack")
|
||||
return
|
||||
}
|
||||
itemName, slot = it.Name, it.Slot
|
||||
} else {
|
||||
it, found := findWornSlot(pd.Equipped, req.Slot)
|
||||
if !found {
|
||||
writeEquipError(w, http.StatusBadRequest, "nothing's in that slot")
|
||||
return
|
||||
}
|
||||
itemName, slot = it.Name, it.Slot
|
||||
}
|
||||
|
||||
since := time.Now().Add(-equipBurstWindow).Unix()
|
||||
if n, err := storage.CountEquipOrdersSince(u.Sub, since); err != nil {
|
||||
slog.Error("equip: burst count", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
} else if n >= equipBurstMax {
|
||||
writeEquipError(w, http.StatusTooManyRequests, "slow down — too many changes in a short while")
|
||||
return
|
||||
}
|
||||
|
||||
characterName := ""
|
||||
if entry, ok, err := storage.RosterEntryByToken(req.Token); err == nil && ok {
|
||||
characterName = entry.Name
|
||||
}
|
||||
|
||||
itemID := req.ItemID
|
||||
if req.Action == storage.EquipActionUnequip {
|
||||
itemID = 0 // a worn item's inventory row is gone; the slot is the handle
|
||||
}
|
||||
order, err := storage.InsertEquipOrder(u.Sub, owner, characterName, itemID, itemName, slot, req.Action)
|
||||
if err != nil {
|
||||
slog.Error("equip: insert order", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("equip: order placed", "guid", order.GUID, "owner", owner, "action", req.Action, "slot", slot)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
// findBackpackItem finds a wearable backpack item by its row id. Only magic items
|
||||
// carry a non-zero ID, so a zero id can never match — the id both names the item
|
||||
// and gates the action to the magic-item equip path.
|
||||
func findBackpackItem(items []storage.ItemView, id int64) (storage.ItemView, bool) {
|
||||
if id == 0 {
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ID == id {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
|
||||
// findWornSlot finds a worn item by the slot it fills.
|
||||
func findWornSlot(items []storage.ItemView, slot string) (storage.ItemView, bool) {
|
||||
if slot == "" {
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Slot == slot {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return storage.ItemView{}, false
|
||||
}
|
||||
|
||||
// handleEquipOrders returns the signed-in owner's own recent equip orders for the
|
||||
// status strip, newest first. Scoped to their OIDC subject.
|
||||
func (s *Server) handleEquipOrders(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
orders, err := storage.EquipOrdersByOwner(u.Sub, 20)
|
||||
if err != nil {
|
||||
slog.Error("equip: orders by owner", "err", err)
|
||||
writeEquipError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.EquipOrder{}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||
|
||||
// equipPollLimit caps one poll, matching the mischief seam.
|
||||
const equipPollLimit = 50
|
||||
|
||||
// handleEquipPending is gogobee's poll: every order still waiting. Like mischief
|
||||
// there is no stale-reoffer window — a gogobee that dies mid-apply leaves the order
|
||||
// pending to be offered again, and gogobee's guid guard makes the replay a no-op.
|
||||
func (s *Server) handleEquipPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orders, err := storage.PendingEquipOrders(equipPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("equip: pending", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.EquipOrder{}
|
||||
}
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// equipVerdict is gogobee's answer on an order: the terminal status and a human
|
||||
// note to render.
|
||||
type equipVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// handleEquipVerdict files gogobee's verdict against a pending order. Idempotent:
|
||||
// gogobee's poll loop retries, so the same verdict can arrive more than once and
|
||||
// only the first moves the order. An unknown guid is a 400 — under the seam's
|
||||
// contract that parks the row for a human rather than retrying forever against a
|
||||
// row that will never exist.
|
||||
func (s *Server) handleEquipVerdict(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v equipVerdict
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.ResolveEquipOrder(v.GUID, v.Status, v.Detail)
|
||||
if errors.Is(err, storage.ErrNoSuchEquipOrder) {
|
||||
slog.Error("equip: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||
http.Error(w, "no such order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("equip: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slog.Info("equip: order resolved", "guid", order.GUID, "status", order.Status)
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
func writeEquipError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The equip queue's web seam. Two contracts: the owner half proves ownership and
|
||||
// resolves the item from Pete's own record (never the client), and the gogobee
|
||||
// half is a bearer-authed, idempotent pending/verdict pair.
|
||||
|
||||
// seedEquip stands up a board + a private detail set owned by `owner`, with a
|
||||
// wearable backpack magic item (ID != 0, the equip handle) and a worn item in a
|
||||
// slot. Mirrors seedWho but pins the fields the equip path keys on.
|
||||
func seedEquip(t *testing.T, owner string) *Server {
|
||||
t.Helper()
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner,
|
||||
Token: "tok-josie",
|
||||
Inventory: []storage.ItemView{
|
||||
// A wearable magic item: carries an ID, so it can be equipped.
|
||||
{ID: 501, Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||
Slot: "ring_1", Attunement: true, Effect: "-8% damage taken"},
|
||||
// Mundane gear: no ID, so no equip handle even though it has a slot.
|
||||
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||
Slot: "weapon", SkillSource: "mining"},
|
||||
},
|
||||
Equipped: []storage.ItemView{
|
||||
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
||||
Effect: "faster to act", Attunement: true, Attuned: true},
|
||||
},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func placeEquip(t *testing.T, s *Server, username string, req equipOrderReq) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "POST", "/api/equip/order", req)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipOrder(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestEquipOrderHappyEquip: the owner equips a backpack magic item; Pete resolves
|
||||
// the item name and slot from its own record and queues a pending order.
|
||||
func TestEquipOrderHappyEquip(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("equip = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var o storage.EquipOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != storage.EquipPending || o.Action != "equip" || o.ItemID != 501 {
|
||||
t.Fatalf("order = %+v", o)
|
||||
}
|
||||
// Name and slot come from Pete's own detail, not the request.
|
||||
if o.ItemName != "Ring of Protection" || o.Slot != "ring_1" || o.CharacterName != "Josie" {
|
||||
t.Fatalf("order didn't resolve from the owner's record: %+v", o)
|
||||
}
|
||||
if pending, _ := storage.PendingEquipOrders(10); len(pending) != 1 {
|
||||
t.Fatal("order didn't land in the pending set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipOrderHappyUnequip: taking off a worn item rides on the slot; item_id 0.
|
||||
func TestEquipOrderHappyUnequip(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "cloak"})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("unequip = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var o storage.EquipOrder
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &o)
|
||||
if o.Action != "unequip" || o.Slot != "cloak" || o.ItemName != "Cloak of Elvenkind" || o.ItemID != 0 {
|
||||
t.Fatalf("unequip order = %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipOrderRejections: the honest failure surface — not your page, item not
|
||||
// in the pack, empty slot, unequippable mundane gear, bad action.
|
||||
func TestEquipOrderRejections(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
|
||||
// A different signed-in user does not own Josie's page.
|
||||
if w := placeEquip(t, s, "mallory", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 403 {
|
||||
t.Errorf("non-owner equip = %d, want 403", w.Code)
|
||||
}
|
||||
// An item id that isn't in the pack.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 999}); w.Code != 400 {
|
||||
t.Errorf("unknown item = %d, want 400", w.Code)
|
||||
}
|
||||
// Mundane gear has a slot but no id, so it can never be named for equip.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 0}); w.Code != 400 {
|
||||
t.Errorf("no-id equip = %d, want 400", w.Code)
|
||||
}
|
||||
// Unequip of a slot nothing is in.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "unequip", Slot: "boots"}); w.Code != 400 {
|
||||
t.Errorf("empty-slot unequip = %d, want 400", w.Code)
|
||||
}
|
||||
// A bogus action.
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "wield", ItemID: 501}); w.Code != 400 {
|
||||
t.Errorf("bad action = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquipWireIdempotentAndAuthed: gogobee's pending/verdict pair is bearer-only,
|
||||
// never nulls, and files a verdict once.
|
||||
func TestEquipWireIdempotentAndAuthed(t *testing.T) {
|
||||
s := seedEquip(t, "reala")
|
||||
if w := placeEquip(t, s, "reala", equipOrderReq{Token: "tok-josie", Action: "equip", ItemID: 501}); w.Code != 200 {
|
||||
t.Fatalf("seed order = %d", w.Code)
|
||||
}
|
||||
// The owner sees their own order in the "my orders" strip.
|
||||
if rows := mustEquipOrders(t, s, "reala"); len(rows) != 1 {
|
||||
t.Fatalf("owner sees %d orders, want 1", len(rows))
|
||||
}
|
||||
|
||||
// No bearer → 401 on both machine endpoints.
|
||||
if w := httptest.NewRecorder(); func() bool {
|
||||
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "", nil))
|
||||
return w.Code == 401
|
||||
}() == false {
|
||||
t.Error("pending without bearer should be 401")
|
||||
}
|
||||
|
||||
// Pending returns the order (bearer-authed), never null.
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipPending(w, jsonReq(t, "GET", "/api/equip/pending", "tok", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("pending = %d", w.Code)
|
||||
}
|
||||
var pending []storage.EquipOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil || len(pending) != 1 {
|
||||
t.Fatalf("pending body = %s err=%v", w.Body.String(), err)
|
||||
}
|
||||
guid := pending[0].GUID
|
||||
|
||||
// A verdict resolves it; a replay is a no-op.
|
||||
verdict := func(status, detail string) *httptest.ResponseRecorder {
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||
equipVerdict{GUID: guid, Status: status, Detail: detail}))
|
||||
return rw
|
||||
}
|
||||
if w := verdict("applied", "worn"); w.Code != 200 {
|
||||
t.Fatalf("verdict = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := verdict("rejected_not_owned", "too late"); w.Code != 200 {
|
||||
t.Fatalf("replay verdict = %d", w.Code)
|
||||
}
|
||||
got, _ := storage.EquipOrderByGUID(guid)
|
||||
if got.Status != storage.EquipApplied || got.Detail != "worn" {
|
||||
t.Fatalf("replay overwrote the first verdict: %+v", got)
|
||||
}
|
||||
|
||||
// Unknown guid parks with a 400, not a silent retry.
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleEquipVerdict(rw, jsonReq(t, "POST", "/api/equip/verdict", "tok",
|
||||
equipVerdict{GUID: "ghost", Status: "applied"}))
|
||||
if rw.Code != 400 {
|
||||
t.Errorf("unknown guid = %d, want 400", rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// mustEquipOrders returns the raw "my orders" JSON rows for a user. Small helper
|
||||
// so the wire test can pull the guid it just created without reaching into storage.
|
||||
func mustEquipOrders(t *testing.T, s *Server, username string) []json.RawMessage {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "GET", "/api/equip/orders", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEquipOrders(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("orders = %d", w.Code)
|
||||
}
|
||||
var rows []json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -255,6 +255,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
|
||||
mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)
|
||||
|
||||
// The equip queue's game-box wire: gogobee polls pending equip/unequip orders
|
||||
// and pushes a verdict. Same bearer token and same reason as the pair above —
|
||||
// the caller is a machine on the tailnet. The owner-facing half hangs off the
|
||||
// auth block below.
|
||||
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||
|
||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||
@@ -282,6 +289,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
|
||||
mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
|
||||
mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)
|
||||
|
||||
// The equip queue, owner side. Signed-in only — an order dresses a
|
||||
// specific owner's character — and gated on the adventure seam like the
|
||||
// storefront, since without a board there is no detail page to equip from.
|
||||
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||
}
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
thanks to the `transition-colors duration-1000` on <body>.
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
/* --warn is the "you should look at this" amber, and it is a phase variable for
|
||||
the same reason --ink is: the phase decides how dark the card underneath is.
|
||||
Tailwind's dark: variant cannot do this job here — darkMode is unconfigured,
|
||||
so dark: follows the OS's prefers-color-scheme, which has nothing to do with
|
||||
which phase Pete is showing. A dark phase under a light OS renders amber-700
|
||||
on the night card at 2.34:1, and a light phase under a dark OS renders
|
||||
amber-400 on cream at 1.55:1 — half of the four combinations unreadable, and
|
||||
which half depends on a setting outside the page. Only night has a dark card;
|
||||
dusk and dawn are lit despite their names. */
|
||||
:root,
|
||||
html[data-phase="day"] {
|
||||
--bg: #fff7e4; /* warm cream */
|
||||
@@ -15,6 +24,7 @@ html[data-phase="day"] {
|
||||
--card: #ffffff;
|
||||
--ink: #3a2e1f;
|
||||
--accent: #f2a541; /* sunshine yellow */
|
||||
--warn: #b45309;
|
||||
}
|
||||
|
||||
html[data-phase="dawn"] {
|
||||
@@ -23,6 +33,7 @@ html[data-phase="dawn"] {
|
||||
--card: #fff4ea;
|
||||
--ink: #4a2e2a;
|
||||
--accent: #ff8a65;
|
||||
--warn: #b45309;
|
||||
}
|
||||
|
||||
html[data-phase="dusk"] {
|
||||
@@ -31,6 +42,7 @@ html[data-phase="dusk"] {
|
||||
--card: #fff1de;
|
||||
--ink: #3d2417;
|
||||
--accent: #e6553a;
|
||||
--warn: #b45309;
|
||||
}
|
||||
|
||||
html[data-phase="night"] {
|
||||
@@ -39,6 +51,7 @@ html[data-phase="night"] {
|
||||
--card: #2d365a;
|
||||
--ink: #f1ecd8; /* moonlight */
|
||||
--accent: #f9d976; /* lantern */
|
||||
--warn: #fbbf24; /* the only dark card, so the only light amber */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -93,6 +106,33 @@ html[data-phase="night"] {
|
||||
.text-theme-lego { color: #b00d0e; }
|
||||
.text-theme-adventure { color: #5836b8; }
|
||||
|
||||
/* Night repaint. The colours above are all tuned to sit on a light card, and
|
||||
nobody checked them against a dark one when the phases were built: on
|
||||
night's #2d365a every single one lands between 1.08:1 (eu) and 3.12:1
|
||||
(finance), i.e. the whole family is below AA and eu is very nearly
|
||||
invisible. Same hue, lifted lightness, saturation floored at 0.62 so the
|
||||
dulled ones stay a colour instead of going grey — all now ≥5.5:1.
|
||||
|
||||
Only night gets this. Dusk and dawn are lit cards despite the names, so
|
||||
they keep the originals exactly. Lego cannot stay pillar-box red and be
|
||||
readable on navy — a red light enough to pass reads as salmon, and that is
|
||||
the honest trade rather than a red nobody can see.
|
||||
|
||||
Deliberately NOT Tailwind's dark: variant: darkMode is unconfigured, so
|
||||
dark: follows the OS's prefers-color-scheme, which knows nothing about
|
||||
which phase Pete is showing. Keyed off the phase, like --ink is. */
|
||||
html[data-phase="night"] .text-theme-gaming { color: #30cb7b; }
|
||||
html[data-phase="night"] .text-theme-tech { color: #7fb8e1; }
|
||||
html[data-phase="night"] .text-theme-politics { color: #e5a191; }
|
||||
html[data-phase="night"] .text-theme-eu { color: #8bb1ff; }
|
||||
html[data-phase="night"] .text-theme-music { color: #caa2e9; }
|
||||
html[data-phase="night"] .text-theme-anime { color: #e89cb6; }
|
||||
html[data-phase="night"] .text-theme-foss { color: #f69d5d; }
|
||||
html[data-phase="night"] .text-theme-kids { color: #19c7ba; }
|
||||
html[data-phase="night"] .text-theme-finance { color: #07cb8e; }
|
||||
html[data-phase="night"] .text-theme-lego { color: #f79898; }
|
||||
html[data-phase="night"] .text-theme-adventure { color: #baa9eb; }
|
||||
|
||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
||||
@@ -2548,3 +2588,116 @@ html[data-room] .pete-felt {
|
||||
.pete-poker-you .pete-seat-cards { --card-h: 6rem; --card-w: 4.3rem; min-height: 6rem; }
|
||||
.pete-poker-pot-total { font-size: 1.25rem; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Dungeon map (who page). The graph arrives already cut to the fog-of-war
|
||||
frontier; who_map.go lays it out and who.html draws it as inline SVG. Node
|
||||
colours ride the phase through --ink/--card like every card does, with a
|
||||
per-kind tint carried in --map-fill so the SVG discs and the legend dots
|
||||
read from one source. The adventure purple marks the room you're in. */
|
||||
.map-svg { display: block; overflow: visible; }
|
||||
|
||||
.map-edge {
|
||||
stroke: color-mix(in srgb, var(--ink) 26%, transparent);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.map-edge-locked {
|
||||
stroke: #c98a2b; /* a barred door reads amber, not ink */
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
|
||||
.map-disc {
|
||||
fill: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||
stroke: color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.map-glyph {
|
||||
fill: color-mix(in srgb, var(--map-stroke, var(--ink)) 85%, var(--ink));
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-node-current .map-disc { stroke: #6d4bd8; stroke-width: 2.5; }
|
||||
.map-ring { fill: none; stroke: #6d4bd8; stroke-width: 2; opacity: 0.5; }
|
||||
|
||||
/* Per-kind tint. Set on the node group (and the legend dot); both the disc
|
||||
fill and the glyph colour derive from it. */
|
||||
.map-node-entry { --map-fill: color-mix(in srgb, #3fa66a 22%, var(--card)); --map-stroke: #2f8a54; }
|
||||
.map-node-boss { --map-fill: color-mix(in srgb, #c0392b 22%, var(--card)); --map-stroke: #a52f22; }
|
||||
.map-node-trap { --map-fill: color-mix(in srgb, #d98324 22%, var(--card)); --map-stroke: #b56a17; }
|
||||
.map-node-elite { --map-fill: color-mix(in srgb, #6d4bd8 20%, var(--card)); --map-stroke: #5836b8; }
|
||||
.map-node-secret { --map-fill: color-mix(in srgb, #b08d2e 22%, var(--card)); --map-stroke: #8f7018; }
|
||||
.map-node-harvest { --map-fill: color-mix(in srgb, #3f8f6a 18%, var(--card)); --map-stroke: #2f7355; }
|
||||
.map-node-rest { --map-fill: color-mix(in srgb, #3f83a6 18%, var(--card)); --map-stroke: #2f6a88; }
|
||||
.map-node-plain { --map-fill: color-mix(in srgb, var(--ink) 9%, var(--card)); --map-stroke: var(--ink); }
|
||||
.map-node-unknown { --map-fill: color-mix(in srgb, var(--ink) 5%, var(--card)); --map-stroke: color-mix(in srgb, var(--ink) 40%, transparent); }
|
||||
.map-node-unknown .map-disc { stroke-dasharray: 3 4; }
|
||||
|
||||
/* Legend swatches. Same --map-fill source as the nodes. */
|
||||
.map-dot {
|
||||
width: 11px; height: 11px; border-radius: 9999px;
|
||||
background: var(--map-fill, color-mix(in srgb, var(--ink) 9%, var(--card)));
|
||||
border: 1.5px solid color-mix(in srgb, var(--map-stroke, var(--ink)) 55%, transparent);
|
||||
}
|
||||
.map-node-unknown.map-dot { border-style: dashed; }
|
||||
.map-door-legend {
|
||||
width: 16px; height: 0;
|
||||
border-top: 2.5px dashed #c98a2b;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Item compare chips (who page, owner's backpack). gogobee decides the verdict
|
||||
and per-stat deltas; Pete only colours them. Every colour mixes a fixed hue
|
||||
into --ink for the text and --card for the fill, so it lands on the readable
|
||||
side of the card in all four phases — the same by-construction contrast trick
|
||||
the dungeon map uses, not a Tailwind dark: variant (which follows the OS, not
|
||||
Pete's phase). The verdict chip is the anchor and always renders; a phone has
|
||||
no hover, so nothing hides behind one. */
|
||||
.cmp-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
font-size: 11px; font-weight: 600; line-height: 1;
|
||||
border-radius: 9999px; padding: 0.18rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.cmp-up {
|
||||
color: color-mix(in srgb, #3fa66a 65%, var(--ink));
|
||||
background: color-mix(in srgb, #3fa66a 16%, var(--card));
|
||||
border-color: color-mix(in srgb, #3fa66a 38%, transparent);
|
||||
}
|
||||
.cmp-down {
|
||||
color: color-mix(in srgb, #c0392b 60%, var(--ink));
|
||||
background: color-mix(in srgb, #c0392b 15%, var(--card));
|
||||
border-color: color-mix(in srgb, #c0392b 36%, transparent);
|
||||
}
|
||||
.cmp-side {
|
||||
color: color-mix(in srgb, var(--ink) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--ink) 8%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--ink) 22%, transparent);
|
||||
}
|
||||
.cmp-new {
|
||||
color: color-mix(in srgb, #6d4bd8 62%, var(--ink));
|
||||
background: color-mix(in srgb, #6d4bd8 15%, var(--card));
|
||||
border-color: color-mix(in srgb, #6d4bd8 36%, transparent);
|
||||
}
|
||||
.cmp-inert {
|
||||
color: var(--warn); /* --warn is already a phase variable — safe on night */
|
||||
background: color-mix(in srgb, var(--warn) 16%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--warn) 36%, transparent);
|
||||
}
|
||||
.cmp-same {
|
||||
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--ink) 6%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--ink) 16%, transparent);
|
||||
}
|
||||
|
||||
/* Per-stat deltas — tint-only, lighter than the verdict chip so it stays the
|
||||
anchor. Green reads as a gain, red as a loss; the engine set the flag. */
|
||||
.cmp-delta {
|
||||
font-size: 11px; font-weight: 500; line-height: 1;
|
||||
border-radius: 9999px; padding: 0.15rem 0.45rem;
|
||||
}
|
||||
.cmp-delta-up { color: color-mix(in srgb, #3fa66a 65%, var(--ink)); background: color-mix(in srgb, #3fa66a 13%, var(--card)); }
|
||||
.cmp-delta-down { color: color-mix(in srgb, #c0392b 60%, var(--ink)); background: color-mix(in srgb, #c0392b 12%, var(--card)); }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+323
-17
@@ -1,5 +1,66 @@
|
||||
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{/* One item, wherever it's sitting. Worn, backpack, and vault all show the
|
||||
same facts; only the panel around them differs. The description and the
|
||||
effect line come from gogobee already resolved — Pete renders them, it does
|
||||
not compute them. "inert" is the honest word for a worn item that wants a
|
||||
bond and hasn't got one: it's on you and doing nothing. */}}
|
||||
{{define "itemrow"}}
|
||||
<li>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1 font-semibold">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">{{if .Tier}}T{{.Tier}}{{end}}{{if .Value}}{{if .Tier}} · {{end}}{{.Value}}g{{end}}</span>
|
||||
</div>
|
||||
{{if or .Slot .SkillSource .Attunement}}
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
{{if .Slot}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.Slot}}</span>{{end}}
|
||||
{{if .SkillSource}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.SkillSource}}</span>{{end}}
|
||||
{{if .Attunement}}{{if .Attuned}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">bonded</span>{{else if .Worn}}<span class="text-[11px] rounded-full bg-amber-400/20 px-2 py-0.5 text-[color:var(--warn)] font-semibold">inert · no bond free</span>{{else}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/10 px-2 py-0.5 text-[color:var(--ink)]/60">needs a bond</span>{{end}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
|
||||
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
|
||||
{{/* The compare card: how this backpack item stacks up against what's worn in
|
||||
the slot it would equip into. gogobee decided the verdict and the per-stat
|
||||
deltas (it holds the tempering and bond math); Pete only colours them. The
|
||||
verdict chip always shows — a phone has no hover to lean on — with the
|
||||
deltas beside it, each already flagged better/worse. */}}
|
||||
{{with .Compare}}
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
{{if eq .Verdict "upgrade"}}<span class="cmp-chip cmp-up">↑ upgrade</span>
|
||||
{{else if eq .Verdict "downgrade"}}<span class="cmp-chip cmp-down">↓ downgrade</span>
|
||||
{{else if eq .Verdict "sidegrade"}}<span class="cmp-chip cmp-side">⇄ sidegrade</span>
|
||||
{{else if eq .Verdict "new"}}<span class="cmp-chip cmp-new">✦ fills {{.VsSlot}}</span>
|
||||
{{else if eq .Verdict "inert"}}<span class="cmp-chip cmp-inert">⚠ would sit inert</span>
|
||||
{{else if eq .Verdict "same"}}<span class="cmp-chip cmp-same">= no change</span>{{end}}
|
||||
{{range .Deltas}}<span class="cmp-delta {{if .Better}}cmp-delta-up{{else}}cmp-delta-down{{end}}">{{.Text}}</span>{{end}}
|
||||
</div>
|
||||
{{if .VsName}}<p class="text-[11px] text-[color:var(--ink)]/45 mt-0.5">vs worn {{.VsName}}</p>{{end}}
|
||||
{{end}}
|
||||
{{if .EquipAction}}
|
||||
<div class="mt-1.5">
|
||||
<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
|
||||
data-action="{{.EquipAction}}" data-item-id="{{.ID}}" data-slot="{{.Slot}}" data-item-name="{{.Name}}">{{if eq .EquipAction "equip"}}Equip{{else}}Take off{{end}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{/* The dungeon map. Laid out server-side (who_map.go) into an x/y node field;
|
||||
this just draws it. Edges first so nodes sit on top of the lines. Nothing
|
||||
here is a player-authored string — node kinds are a fixed vocabulary and
|
||||
ids are zone-prefixed — so the SVG carries no injection surface. */}}
|
||||
{{define "dungeonmap"}}
|
||||
<svg viewBox="0 0 {{.W}} {{.H}}" width="{{.W}}" height="{{.H}}" role="img" aria-label="Dungeon map" class="map-svg max-w-full h-auto">
|
||||
<g>
|
||||
{{range .Edges}}<line x1="{{.X1}}" y1="{{.Y1}}" x2="{{.X2}}" y2="{{.Y2}}" class="map-edge{{if .Locked}} map-edge-locked{{end}}">{{if .Lock}}<title>{{.Lock}}</title>{{end}}</line>{{end}}
|
||||
</g>
|
||||
<g>
|
||||
{{range .Nodes}}<g class="map-node {{.Class}}{{if .Current}} map-node-current{{end}}" transform="translate({{.X}},{{.Y}})">{{if .Current}}<circle r="21" class="map-ring"></circle>{{end}}<circle r="15" class="map-disc"></circle><text text-anchor="middle" dominant-baseline="central" class="map-glyph">{{.Glyph}}</text><title>{{.Label}}{{if .Current}} — you are here{{end}}</title></g>{{end}}
|
||||
</g>
|
||||
</svg>
|
||||
{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||
<nav class="mb-4">
|
||||
@@ -73,12 +134,153 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .MapView}}
|
||||
<!-- The dungeon map: the fog-of-war cut gogobee sent. Visited rooms drawn in
|
||||
full, the doors leading out of them, and the unopened rooms behind those
|
||||
doors as question marks. Pete lays it out; it is never told what a room
|
||||
holds, only that a door is there. -->
|
||||
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<div class="flex items-baseline justify-between mb-4">
|
||||
<h2 class="font-display text-xl font-bold">The map</h2>
|
||||
{{if .Detail.Room}}<span class="text-sm text-[color:var(--ink)]/50">Room <span class="font-semibold text-[color:var(--ink)]/70">{{.Detail.Room}}</span></span>{{end}}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
{{template "dungeonmap" .MapView}}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-[color:var(--ink)]/55">
|
||||
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-entry"></span>entrance</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-boss"></span>boss</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-unknown"></span>unexplored</span>
|
||||
<span class="inline-flex items-center gap-1.5"><span class="map-door-legend"></span>locked door</span>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasHistory}}
|
||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||
same information the /adventure feed already printed, only as numbers
|
||||
instead of as forty separate sentences. Not a gogobee snapshot: Pete
|
||||
counted these itself out of the facts it kept. -->
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">The record</h2>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.TreasuresFound}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Retreats}}</div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">walked out</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .Trophies.BossFirsts .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}
|
||||
<p class="mt-3 text-sm text-theme-adventure font-semibold">
|
||||
★ {{if .Trophies.BossFirsts}}{{.Trophies.BossFirsts}} realm-first boss{{if ne .Trophies.BossFirsts 1}}es{{end}}{{end}}{{if and .Trophies.BossFirsts (or .Trophies.ZoneFirsts .Trophies.TreasureFirsts)}}, {{end}}{{if .Trophies.ZoneFirsts}}{{.Trophies.ZoneFirsts}} first clear{{if ne .Trophies.ZoneFirsts 1}}s{{end}}{{end}}{{if and .Trophies.ZoneFirsts .Trophies.TreasureFirsts}}, {{end}}{{if .Trophies.TreasureFirsts}}{{.Trophies.TreasureFirsts}} first hoard{{if ne .Trophies.TreasureFirsts 1}}s{{end}}{{end}}. Nobody had done it before.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<div class="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
{{if .Trophies.Bosses}}
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-bold mb-3">Bosses fought</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||
{{range .Trophies.Bosses}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Boss}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to clear it">★</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Kills}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Trophies.Zones}}
|
||||
<div>
|
||||
<h3 class="font-display text-lg font-bold mb-3">Ground covered</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||
{{range .Trophies.Zones}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-[color:var(--ink)]/45">{{.Region}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Clears}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Trophies.Treasures}}
|
||||
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||||
<h3 class="font-display text-lg font-bold mb-3">Treasures found</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
|
||||
{{range .Trophies.Treasures}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">💎 {{.Item}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to pull this hoard">★</span>{{end}}</span>
|
||||
{{if .Zone}}<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.Zone}}</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Trophies.Milestones}}
|
||||
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
|
||||
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Trophies.Milestones}}
|
||||
<span class="rounded-full bg-theme-adventure/10 text-theme-adventure text-xs font-semibold px-3 py-1">🏅 {{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- The trail: every fact we have, newest first, each linking to the dispatch
|
||||
that told it. -->
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">The trail</h2>
|
||||
<ol class="relative border-l-2 border-[color:var(--ink)]/10 ml-3 space-y-4">
|
||||
{{range .Timeline}}
|
||||
<li class="relative pl-6">
|
||||
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-[color:var(--card)] border-2 {{if .Notable}}border-theme-adventure{{else}}border-[color:var(--ink)]/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
|
||||
<a href="{{.Permalink}}" class="group block">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="font-semibold group-hover:text-theme-adventure transition {{if .Notable}}text-theme-adventure{{end}}">{{.Label}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.When}}</span>
|
||||
</div>
|
||||
{{if .Line}}<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">{{.Line}}</p>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{if .MoreHistory}}
|
||||
<p class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 text-xs text-[color:var(--ink)]/45">
|
||||
Showing the most recent {{len .Timeline}}. The counts above cover everything.
|
||||
</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasSelf}}
|
||||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||
only because your signed-in localpart owns this page's token. -->
|
||||
@@ -113,32 +315,39 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
{{if .Worn}}
|
||||
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
|
||||
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
|
||||
<ul class="space-y-2.5 text-sm">
|
||||
{{range .Worn}}{{template "itemrow" .}}{{end}}
|
||||
</ul>
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Backpack</h3>
|
||||
{{else}}
|
||||
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||||
{{if .Self.Inventory}}
|
||||
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||
{{range .Self.Inventory}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Backpack}}
|
||||
<ul class="space-y-2.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||
{{range .Backpack}}{{template "itemrow" .}}{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Self.Vault}}
|
||||
{{if .VaultRows}}
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||
{{range .Self.Vault}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
<ul class="space-y-2.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||
{{range .VaultRows}}{{template "itemrow" .}}{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<!-- Changes you've asked for. The queue is honest: an equip lands on the
|
||||
game box's next poll, so a fresh order reads "queued", never "done".
|
||||
JS fills this from /api/equip/orders. -->
|
||||
<div id="equip-orders" class="mt-6 hidden">
|
||||
<h3 class="font-display text-lg font-bold mb-2">Pending changes</h3>
|
||||
<ul id="equip-orders-list" class="space-y-1.5 text-xs"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -203,5 +412,102 @@
|
||||
}
|
||||
timer = setInterval(refresh, 60000);
|
||||
})();
|
||||
|
||||
// The equip queue, owner side. Clicking Equip / Take off records the intent; the
|
||||
// item actually moves on the game box's next poll. So the UI never claims a change
|
||||
// landed — it shows "queued" and lets the order's own status be the truth.
|
||||
(function () {
|
||||
var panel = document.getElementById('gear-panel');
|
||||
if (!panel) return; // only the owner gets this panel at all
|
||||
var token = panel.getAttribute('data-token');
|
||||
var box = document.getElementById('equip-orders');
|
||||
var list = document.getElementById('equip-orders-list');
|
||||
|
||||
// How each terminal status reads to the owner. gogobee only rejects when the
|
||||
// item slipped out from under the order or can't be worn — a bond-cap "inert"
|
||||
// is still an applied change, and its detail line says so.
|
||||
var STATUS = {
|
||||
pending: 'queued',
|
||||
applied: 'done',
|
||||
rejected_not_owned: "couldn't — that item had already moved",
|
||||
rejected_not_worn: "couldn't — that slot was already empty",
|
||||
rejected_not_equippable: "couldn't — that item can't be worn"
|
||||
};
|
||||
|
||||
var pollTimer = null;
|
||||
|
||||
function render(orders) {
|
||||
list.innerHTML = '';
|
||||
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||
box.classList.remove('hidden');
|
||||
var anyPending = false;
|
||||
orders.forEach(function (o) {
|
||||
if (o.status === 'pending') anyPending = true;
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-baseline justify-between gap-3';
|
||||
var verb = o.action === 'equip' ? 'Equip' : 'Take off';
|
||||
var left = document.createElement('span');
|
||||
left.className = 'flex-1';
|
||||
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
|
||||
var right = document.createElement('span');
|
||||
right.className = 'shrink-0 ' + (o.status === 'pending'
|
||||
? 'text-[color:var(--ink)]/45'
|
||||
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||
right.textContent = o.detail || STATUS[o.status] || o.status;
|
||||
li.appendChild(left); li.appendChild(right);
|
||||
list.appendChild(li);
|
||||
});
|
||||
// While anything is still queued, keep refreshing so the verdict lands without
|
||||
// a reload; stop once everything is terminal.
|
||||
if (anyPending && !pollTimer) {
|
||||
pollTimer = setInterval(loadOrders, 15000);
|
||||
} else if (!anyPending && pollTimer) {
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch('/api/equip/orders', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (o) { if (o) render(o); })
|
||||
.catch(function () { /* transient — a later tick will do */ });
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.equip-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
btn.textContent = 'queuing…';
|
||||
fetch('/api/equip/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
action: btn.getAttribute('data-action'),
|
||||
item_id: parseInt(btn.getAttribute('data-item-id') || '0', 10),
|
||||
slot: btn.getAttribute('data-slot') || ''
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||
return;
|
||||
}
|
||||
btn.textContent = 'queued';
|
||||
loadOrders();
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = 'try again';
|
||||
});
|
||||
});
|
||||
|
||||
loadOrders();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
@@ -32,6 +33,30 @@ type whoDetail struct {
|
||||
Supplies int `json:"supplies"`
|
||||
ThreatLevel int `json:"threat_level"`
|
||||
Room string `json:"room"`
|
||||
Map *whoMap `json:"map"`
|
||||
}
|
||||
|
||||
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||
// their true kind, plus the one-hop frontier of doors whose rooms are withheld
|
||||
// (kind "unknown"). Pete lays it out and draws it; it never receives node
|
||||
// labels or contents, only ids and kinds. See who_map.go.
|
||||
type whoMap struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
CurrentNode string `json:"current_node"`
|
||||
Visited []string `json:"visited"`
|
||||
Nodes []whoMapNode `json:"nodes"`
|
||||
Edges []whoMapEdge `json:"edges"`
|
||||
}
|
||||
|
||||
type whoMapNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type whoMapEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Lock string `json:"lock"`
|
||||
}
|
||||
|
||||
type whoGear struct {
|
||||
@@ -57,10 +82,86 @@ type whoPage struct {
|
||||
HasDetail bool
|
||||
Detail whoDetail
|
||||
Abilities []abilityRow
|
||||
MapView *mapView // laid-out dungeon map, nil when not on a run or no graph
|
||||
HasSelf bool
|
||||
Self storage.PlayerDetail
|
||||
// The private panels, wrapped so a row knows where it is sitting. Bond state
|
||||
// only means something on a worn item — see itemRow.
|
||||
Worn []itemRow
|
||||
Backpack []itemRow
|
||||
VaultRows []itemRow
|
||||
BondsUsed int
|
||||
// History. Unlike everything above, these are not a gogobee snapshot — they
|
||||
// are counted from the facts Pete has been keeping since adventure_events
|
||||
// landed. HasHistory is false for an adventurer who hasn't done anything since
|
||||
// then, which includes every veteran on the day this shipped: their past is in
|
||||
// the story feed as prose and cannot be counted back out.
|
||||
Trophies storage.TrophyCase
|
||||
HasHistory bool
|
||||
Timeline []timelineEntry
|
||||
MoreHistory bool // the trail was capped; there is older history than this
|
||||
}
|
||||
|
||||
// itemRow is one item plus the one thing the item itself can't tell you: which
|
||||
// panel it's in. It matters for bond state. An attunement item that is worn
|
||||
// without a bond is *inert* — on you, doing nothing, and worth shouting about.
|
||||
// The same item in a backpack isn't inert, it's just not worn yet; gogobee only
|
||||
// tracks bonds on equipped rows, so its Attuned is undefined rather than false.
|
||||
// Rendering both as "inert" would invent a problem the player doesn't have.
|
||||
type itemRow struct {
|
||||
storage.ItemView
|
||||
Worn bool
|
||||
// EquipAction is the control this row offers its owner: "unequip" on anything
|
||||
// worn, "equip" on a backpack item the magic-item path will accept, "" on
|
||||
// everything else (vault items, mundane backpack gear). Empty means no button.
|
||||
EquipAction string
|
||||
}
|
||||
|
||||
// itemRows wraps gogobee's item views for one panel. panel is "worn", "backpack",
|
||||
// or "vault"; it decides both the bond wording (only a worn item can be inert) and
|
||||
// which equip control, if any, the row offers.
|
||||
func itemRows(items []storage.ItemView, panel string) []itemRow {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
worn := panel == "worn"
|
||||
out := make([]itemRow, 0, len(items))
|
||||
for _, it := range items {
|
||||
row := itemRow{ItemView: it, Worn: worn}
|
||||
switch {
|
||||
case worn:
|
||||
// Everything in magic_item_equipped is a magic item and can come off; the
|
||||
// slot is the handle gogobee unequips by.
|
||||
row.EquipAction = storage.EquipActionUnequip
|
||||
case panel == "backpack" && it.ID != 0:
|
||||
// Only a wearable magic item carries a row id (gogobee sets it just in the
|
||||
// magic-item branch), so the id gates Equip to exactly the items the
|
||||
// magic-item path accepts — never mundane gear, never a vault item.
|
||||
row.EquipAction = storage.EquipActionEquip
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timelineEntry is one line of an adventurer's trail — a fact, rendered short,
|
||||
// pointing at the dispatch that told it.
|
||||
type timelineEntry struct {
|
||||
Emoji string
|
||||
Label string
|
||||
Line string // "The Rotmother, in holymachina"
|
||||
When string
|
||||
Permalink string
|
||||
Notable bool // a realm-first or a death: the lines worth an eye
|
||||
}
|
||||
|
||||
// timelineCap bounds the trail. An adventurer accrues facts for as long as they
|
||||
// play, and the page renders the whole list server-side — this is the point where
|
||||
// "their whole history" stops being a page and starts being a scroll nobody
|
||||
// reads. The count in the trophy case stays honest either way; only the trail is
|
||||
// clipped.
|
||||
const timelineCap = 40
|
||||
|
||||
// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
|
||||
// token isn't on the current board — the same liveness gate the storefront uses,
|
||||
// so a stale or guessed token never resolves to a page.
|
||||
@@ -94,6 +195,25 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
page.HasDetail = true
|
||||
page.Detail = d
|
||||
page.Abilities = abil
|
||||
page.MapView = buildMapView(d.Map)
|
||||
}
|
||||
|
||||
// History: one read feeds both the trophy case and the trail. Keyed on the
|
||||
// character *name* rather than the page token, because that is what a fact
|
||||
// carries — see the adventure_events schema note on why there is no id to use.
|
||||
// Best-effort: a page that can't count trophies is still a page.
|
||||
// Unlimited on purpose: the trophy case counts these, and a limit would cap the
|
||||
// tally rather than the list. The trail is clipped below, after the counting.
|
||||
if events, err := storage.EventsBySubject(entry.Name, 0); err != nil {
|
||||
slog.Error("who: history lookup failed", "token", token, "err", err)
|
||||
} else if len(events) > 0 {
|
||||
page.HasHistory = true
|
||||
page.Trophies = storage.BuildTrophyCase(entry.Name, events)
|
||||
page.MoreHistory = len(events) > timelineCap
|
||||
if page.MoreHistory {
|
||||
events = events[:timelineCap]
|
||||
}
|
||||
page.Timeline = buildTimeline(s, entry.Name, events)
|
||||
}
|
||||
|
||||
// Owner enrichment: only when the signed-in user's localpart owns this exact
|
||||
@@ -104,6 +224,14 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
|
||||
page.HasSelf = true
|
||||
page.Self = self
|
||||
page.Worn = itemRows(self.Equipped, "worn")
|
||||
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||
page.VaultRows = itemRows(self.Vault, "vault")
|
||||
for _, it := range self.Equipped {
|
||||
if it.Attuned {
|
||||
page.BondsUsed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +272,76 @@ func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// buildTimeline renders facts into trail lines.
|
||||
//
|
||||
// The line is built from the fact, not from the dispatch's headline, on purpose:
|
||||
// the headline is a *news* sentence written for the moment it landed ("First
|
||||
// ever: Josie brings down the Rotmother"), and forty of those stacked in a column
|
||||
// read as a wall of shouting. The trail wants the noun, not the announcement. It
|
||||
// also saves a join back to stories for every row.
|
||||
func buildTimeline(s *Server, subject string, events []storage.AdvEvent) []timelineEntry {
|
||||
out := make([]timelineEntry, 0, len(events))
|
||||
for _, e := range events {
|
||||
label, emoji := advEventMeta(e.EventType)
|
||||
out = append(out, timelineEntry{
|
||||
Emoji: emoji,
|
||||
Label: label,
|
||||
Line: timelineLine(subject, e),
|
||||
When: time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(e.GUID),
|
||||
// A realm-first hoard is the treasure worth an eye; a plain find rides
|
||||
// the quiet border like any other bulletin.
|
||||
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death" ||
|
||||
(e.EventType == "treasure_found" && e.Tier == "priority"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timelineLine is the short "what" of a fact: the monster, the zone, the rival.
|
||||
// Empty is fine — the label and emoji already carry the event, and inventing
|
||||
// filler for a fact that carries no nouns would just be noise.
|
||||
func timelineLine(subject string, e storage.AdvEvent) string {
|
||||
inZone := func(s string) string {
|
||||
if e.Zone == "" {
|
||||
return s
|
||||
}
|
||||
if s == "" {
|
||||
return e.Zone
|
||||
}
|
||||
return s + ", in " + e.Zone
|
||||
}
|
||||
switch e.EventType {
|
||||
case "boss_first", "boss_kill", "siege_start", "siege_win", "siege_loss",
|
||||
"mischief_survived", "mischief_downed":
|
||||
return inZone(e.Boss)
|
||||
case "zone_first", "zone_clear", "retreat", "departure", "death":
|
||||
if e.Region != "" && e.Zone != "" {
|
||||
return e.Zone + " — " + e.Region
|
||||
}
|
||||
return e.Zone
|
||||
case "rival_result", "pete_duel_win", "pete_duel_loss":
|
||||
// Name the *other* party. On the winner's page the rival is the opponent;
|
||||
// on the loser's page this fact arrives via the opponent column, so the
|
||||
// rival is the subject — naming e.Opponent there would point the viewer at
|
||||
// themselves. Pete duels carry no character opponent, so this stays empty.
|
||||
other := e.Opponent
|
||||
if e.Opponent == subject {
|
||||
other = e.Subject
|
||||
}
|
||||
if other != "" && other != subject {
|
||||
return "vs " + other
|
||||
}
|
||||
return ""
|
||||
case "milestone":
|
||||
return e.Milestone
|
||||
case "treasure_found":
|
||||
// The item is the point; name it, and say where it came out of.
|
||||
return inZone(e.Stakes)
|
||||
}
|
||||
return inZone("")
|
||||
}
|
||||
|
||||
// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
|
||||
// Returns ok=false when there is no detail (a snapshot from before the detail
|
||||
// push), so the page can fall back to the summary the board already has.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package web
|
||||
|
||||
// Dungeon map rendering.
|
||||
//
|
||||
// gogobee sends the fog-of-war cut of an adventurer's zone graph (whoMap):
|
||||
// every room they have visited with its true kind, plus the one-hop frontier of
|
||||
// doors leading out of visited rooms, with the rooms behind them withheld as
|
||||
// kind "unknown". Pete lays that graph out and draws it as an inline SVG on the
|
||||
// who page. No node labels or contents ever cross the wire — the map knows a
|
||||
// room is a trap, never what the trap is.
|
||||
//
|
||||
// Layout is a left-to-right layering: column = BFS depth from the entry along
|
||||
// the edges we were given, row = order the node first appears in the payload.
|
||||
// It is computed server-side and fully deterministic, so the same run draws the
|
||||
// same map every render.
|
||||
|
||||
// Layout geometry. The node/ring radii live in who.html and the map CSS; only
|
||||
// the spacing the layout math needs is here.
|
||||
const (
|
||||
mapColGap = 84 // horizontal spacing between depth columns
|
||||
mapRowGap = 60 // vertical spacing between nodes sharing a column
|
||||
mapMargin = 26 // padding around the node field
|
||||
)
|
||||
|
||||
// mapView is the laid-out map, ready for the template's SVG.
|
||||
type mapView struct {
|
||||
Nodes []mapNode
|
||||
Edges []mapEdge
|
||||
W int // svg viewBox width
|
||||
H int // svg viewBox height
|
||||
}
|
||||
|
||||
type mapNode struct {
|
||||
X, Y int
|
||||
Kind string // ZoneNodeKind, or "unknown" for an unreached frontier room
|
||||
Glyph string // the single character drawn inside the node
|
||||
Class string // CSS class picking the node's colour family
|
||||
Label string // accessible title, e.g. "Trap" or "Unexplored"
|
||||
Current bool // the room the adventurer is standing in
|
||||
}
|
||||
|
||||
type mapEdge struct {
|
||||
X1, Y1, X2, Y2 int
|
||||
Locked bool
|
||||
Lock string // the gate's kind, for the door's title text
|
||||
}
|
||||
|
||||
// nodeStyle maps a room kind to how it draws. Kept small and total: an unknown
|
||||
// kind (a room type Pete hasn't been taught yet) falls through to a neutral dot
|
||||
// rather than vanishing.
|
||||
type nodeStyle struct {
|
||||
glyph string
|
||||
class string
|
||||
label string
|
||||
}
|
||||
|
||||
var mapNodeStyles = map[string]nodeStyle{
|
||||
"entry": {"⌂", "map-node-entry", "Entrance"},
|
||||
"exploration": {"·", "map-node-plain", "Room"},
|
||||
"trap": {"!", "map-node-trap", "Trap"},
|
||||
"elite": {"◆", "map-node-elite", "Elite"},
|
||||
"boss": {"♛", "map-node-boss", "Boss"}, // text dingbats only — U+26CF/U+2620 fall back to emoji or tofu
|
||||
"harvest": {"❖", "map-node-harvest", "Harvest"},
|
||||
"rest_camp": {"✚", "map-node-rest", "Rest camp"},
|
||||
"secret": {"✦", "map-node-secret", "Secret"},
|
||||
"fork": {"⋔", "map-node-plain", "Fork"},
|
||||
"merge": {"⋏", "map-node-plain", "Merge"},
|
||||
"unknown": {"?", "map-node-unknown", "Unexplored"},
|
||||
}
|
||||
|
||||
func styleFor(kind string) nodeStyle {
|
||||
if s, ok := mapNodeStyles[kind]; ok {
|
||||
return s
|
||||
}
|
||||
return nodeStyle{"·", "map-node-plain", "Room"}
|
||||
}
|
||||
|
||||
// buildMapView lays out a fog-of-war graph. Returns nil when there is nothing
|
||||
// worth drawing (no nodes) so the template can skip the panel entirely.
|
||||
func buildMapView(m *whoMap) *mapView {
|
||||
if m == nil || len(m.Nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Adjacency for the depth walk, and appearance order for stable rows.
|
||||
order := make(map[string]int, len(m.Nodes))
|
||||
for i, n := range m.Nodes {
|
||||
if _, dup := order[n.ID]; !dup {
|
||||
order[n.ID] = i
|
||||
}
|
||||
}
|
||||
adj := make(map[string][]string, len(m.Nodes))
|
||||
for _, e := range m.Edges {
|
||||
// Only lay out edges whose endpoints are both nodes we were given; a
|
||||
// half-edge would point at nothing.
|
||||
if _, ok := order[e.From]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := order[e.To]; !ok {
|
||||
continue
|
||||
}
|
||||
adj[e.From] = append(adj[e.From], e.To)
|
||||
}
|
||||
|
||||
// Depth = shortest hop count from the entry. The first visited node is the
|
||||
// entry (gogobee sends Visited in path order); fall back to appearance
|
||||
// order 0 if Visited is somehow empty.
|
||||
start := ""
|
||||
if len(m.Visited) > 0 {
|
||||
start = m.Visited[0]
|
||||
} else {
|
||||
start = m.Nodes[0].ID
|
||||
}
|
||||
depth := bfsDepth(start, adj, order)
|
||||
|
||||
// Group nodes by depth column, each column ordered by first appearance.
|
||||
maxDepth := 0
|
||||
byCol := map[int][]string{}
|
||||
placed := make(map[string]bool, len(m.Nodes))
|
||||
for _, n := range m.Nodes {
|
||||
// Two visited rooms can both list a door to the same withheld frontier
|
||||
// room, so the same id can appear twice in Nodes. Place it once, or it
|
||||
// draws as two overlapping discs and its edges resolve to whichever copy
|
||||
// landed last.
|
||||
if placed[n.ID] {
|
||||
continue
|
||||
}
|
||||
placed[n.ID] = true
|
||||
d := depth[n.ID]
|
||||
byCol[d] = append(byCol[d], n.ID)
|
||||
if d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
maxRows := 0
|
||||
for d := 0; d <= maxDepth; d++ {
|
||||
sortByOrder(byCol[d], order)
|
||||
if len(byCol[d]) > maxRows {
|
||||
maxRows = len(byCol[d])
|
||||
}
|
||||
}
|
||||
|
||||
kindByID := make(map[string]string, len(m.Nodes))
|
||||
for _, n := range m.Nodes {
|
||||
kindByID[n.ID] = n.Kind
|
||||
}
|
||||
|
||||
// Place nodes. Each column is vertically centred against the tallest column
|
||||
// so the map reads as balanced rather than top-aligned.
|
||||
pos := make(map[string][2]int, len(m.Nodes))
|
||||
view := &mapView{
|
||||
W: mapMargin*2 + maxDepth*mapColGap,
|
||||
H: mapMargin*2 + max(0, maxRows-1)*mapRowGap,
|
||||
}
|
||||
if view.W < mapMargin*2 {
|
||||
view.W = mapMargin * 2
|
||||
}
|
||||
for d := 0; d <= maxDepth; d++ {
|
||||
col := byCol[d]
|
||||
x := mapMargin + d*mapColGap
|
||||
offset := (maxRows - len(col)) * mapRowGap / 2
|
||||
for i, id := range col {
|
||||
y := mapMargin + offset + i*mapRowGap
|
||||
pos[id] = [2]int{x, y}
|
||||
}
|
||||
}
|
||||
|
||||
for d := 0; d <= maxDepth; d++ {
|
||||
for _, id := range byCol[d] {
|
||||
p := pos[id]
|
||||
st := styleFor(kindByID[id])
|
||||
view.Nodes = append(view.Nodes, mapNode{
|
||||
X: p[0], Y: p[1],
|
||||
Kind: kindByID[id],
|
||||
Glyph: st.glyph,
|
||||
Class: st.class,
|
||||
Label: st.label,
|
||||
Current: id == m.CurrentNode,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, e := range m.Edges {
|
||||
a, okA := pos[e.From]
|
||||
b, okB := pos[e.To]
|
||||
if !okA || !okB {
|
||||
continue
|
||||
}
|
||||
view.Edges = append(view.Edges, mapEdge{
|
||||
X1: a[0], Y1: a[1], X2: b[0], Y2: b[1],
|
||||
Locked: e.Lock != "" && e.Lock != "none",
|
||||
Lock: e.Lock,
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// bfsDepth returns the hop distance from start to every node reachable along
|
||||
// adj. Nodes never reached (there should be none in a well-formed cut) default
|
||||
// to 0, so a stray node still lands in the first column rather than off-canvas.
|
||||
func bfsDepth(start string, adj map[string][]string, order map[string]int) map[string]int {
|
||||
depth := map[string]int{start: 0}
|
||||
queue := []string{start}
|
||||
for len(queue) > 0 {
|
||||
u := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, v := range adj[u] {
|
||||
if _, seen := depth[v]; !seen {
|
||||
depth[v] = depth[u] + 1
|
||||
queue = append(queue, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any node with an appearance order but no depth (unreachable via the cut)
|
||||
// gets 0 so it is still placed.
|
||||
for id := range order {
|
||||
if _, ok := depth[id]; !ok {
|
||||
depth[id] = 0
|
||||
}
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// sortByOrder sorts ids in place by their first-appearance index. Insertion
|
||||
// sort — columns hold a handful of nodes, and it keeps the ordering stable
|
||||
// without pulling in a comparator closure.
|
||||
func sortByOrder(ids []string, order map[string]int) {
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && order[ids[j]] < order[ids[j-1]]; j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/storage"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fogMap is the shape gogobee sends: two visited rooms (entry, elite), and two
|
||||
// frontier rooms reachable one hop out — n4 is really the boss but arrives with
|
||||
// its kind withheld as "unknown". n3 sits behind a perception-locked door, n4
|
||||
// behind a key-locked one.
|
||||
func fogMap() *whoMap {
|
||||
return &whoMap{
|
||||
ZoneID: "ossuary",
|
||||
CurrentNode: "n2",
|
||||
Visited: []string{"n1", "n2"},
|
||||
Nodes: []whoMapNode{
|
||||
{ID: "n1", Kind: "entry"},
|
||||
{ID: "n2", Kind: "elite"},
|
||||
{ID: "n3", Kind: "unknown"},
|
||||
{ID: "n4", Kind: "unknown"},
|
||||
},
|
||||
Edges: []whoMapEdge{
|
||||
{From: "n1", To: "n2", Lock: ""},
|
||||
{From: "n1", To: "n3", Lock: "perception_check"},
|
||||
{From: "n2", To: "n4", Lock: "key_required"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMapView_Layout(t *testing.T) {
|
||||
v := buildMapView(fogMap())
|
||||
if v == nil {
|
||||
t.Fatal("nil view for a non-empty map")
|
||||
}
|
||||
if len(v.Nodes) != 4 {
|
||||
t.Fatalf("want 4 nodes, got %d", len(v.Nodes))
|
||||
}
|
||||
|
||||
// Depth places entry in column 0, its two successors in column 1, the boss
|
||||
// frontier in column 2 — a left-to-right dungeon.
|
||||
var n1, n2, n4 mapNode
|
||||
for _, n := range v.Nodes {
|
||||
switch {
|
||||
case n.Kind == "entry":
|
||||
n1 = n
|
||||
case n.Kind == "elite":
|
||||
n2 = n
|
||||
case n.Kind == "unknown" && n.X > n1.X+mapColGap:
|
||||
n4 = n // the deepest unknown is n4
|
||||
}
|
||||
}
|
||||
if !(n1.X < n2.X && n2.X < n4.X) {
|
||||
t.Errorf("columns not left-to-right: entry=%d elite=%d frontier=%d", n1.X, n2.X, n4.X)
|
||||
}
|
||||
// The boss frontier must NOT reveal its kind or its glyph.
|
||||
if n4.Kind != "unknown" || n4.Glyph != "?" {
|
||||
t.Errorf("frontier boss leaked: kind=%q glyph=%q", n4.Kind, n4.Glyph)
|
||||
}
|
||||
if n2.Current != true {
|
||||
t.Errorf("current node n2 (elite) should be marked current")
|
||||
}
|
||||
if n1.Current {
|
||||
t.Errorf("entry is not the current node")
|
||||
}
|
||||
|
||||
// Edges: three total, the two locked ones dashed, the visited-visited one open.
|
||||
if len(v.Edges) != 3 {
|
||||
t.Fatalf("want 3 edges, got %d", len(v.Edges))
|
||||
}
|
||||
locked := 0
|
||||
for _, e := range v.Edges {
|
||||
if e.Locked {
|
||||
locked++
|
||||
}
|
||||
}
|
||||
if locked != 2 {
|
||||
t.Errorf("want 2 locked doors (perception, key), got %d", locked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMapView_EmptyIsNil(t *testing.T) {
|
||||
if buildMapView(nil) != nil {
|
||||
t.Error("nil map should give nil view")
|
||||
}
|
||||
if buildMapView(&whoMap{ZoneID: "x"}) != nil {
|
||||
t.Error("map with no nodes should give nil view")
|
||||
}
|
||||
}
|
||||
|
||||
// publicDetailWithMap is publicDetail plus a fog-of-war map, the way the roster
|
||||
// push carries both.
|
||||
func publicDetailWithMap(t *testing.T) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"hp_current": 30,
|
||||
"hp_max": 42,
|
||||
"armor_class": 17,
|
||||
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||
"supplies": 8,
|
||||
"room": "2 / 4",
|
||||
"map": fogMap(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestWhoMapRenders drives the real template with a map present and asserts the
|
||||
// SVG lands, the current room is marked, and the fog holds: the frontier boss
|
||||
// is drawn as an unexplored room, never as a boss.
|
||||
func TestWhoMapRenders(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
e.Detail = publicDetailWithMap(t)
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
|
||||
w := getWho(t, s, "tok-josie", "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{"The map", "map-svg", "map-node-current", "map-edge-locked"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("map render missing %q", want)
|
||||
}
|
||||
}
|
||||
// The elite room glyph shows (visited); the boss glyph must not (the boss is
|
||||
// only reachable as an unrevealed frontier door).
|
||||
if !strings.Contains(body, "◆") {
|
||||
t.Error("visited elite room should show its glyph")
|
||||
}
|
||||
if strings.Contains(body, "♛") {
|
||||
t.Error("fog leak: the boss glyph rendered for an unexplored frontier room")
|
||||
}
|
||||
}
|
||||
+175
-4
@@ -71,16 +71,71 @@ func seedWho(t *testing.T, owner string) *Server {
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner,
|
||||
Token: "tok-josie",
|
||||
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
Inventory: []storage.ItemView{
|
||||
{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10},
|
||||
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||
Slot: "weapon", SkillSource: "mining", Desc: "A pick balanced for a long seam."},
|
||||
// Wants a bond, but it's in the backpack: not worn, so not inert.
|
||||
{Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
|
||||
Slot: "ring", Attunement: true, Effect: "-8% damage taken"},
|
||||
// A wearable magic item (carries an equip id) with gogobee's compare
|
||||
// card attached: a sidegrade against the worn Flame Tongue.
|
||||
{Name: "Blazing Brand", Type: "weapon", Tier: 5, Value: 4000, ID: 77,
|
||||
Slot: "main_hand", Desc: "A blade that hums.", Effect: "+20% damage",
|
||||
Compare: &storage.ItemCompare{
|
||||
Verdict: "sidegrade", VsName: "Flame Tongue", VsSlot: "main_hand",
|
||||
Deltas: []storage.ItemDelta{
|
||||
{Label: "damage", Better: true, Text: "+5% damage"},
|
||||
{Label: "hp", Better: false, Text: "-4 HP"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Equipped: []storage.ItemView{
|
||||
{Name: "Flame Tongue", Type: "weapon", Value: 5000, Temper: 1, Slot: "weapon",
|
||||
Desc: "A sword that ignites on command.", Effect: "+15% damage",
|
||||
Attunement: true, Attuned: true},
|
||||
// Worn while the bond cap is full: on her, doing nothing.
|
||||
{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
|
||||
Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
|
||||
},
|
||||
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// seedHistory pushes facts about Josie through the real ingest path, so the
|
||||
// history panels are driven by rows that arrived the way gogobee sends them
|
||||
// rather than by a hand-built struct that could drift from the contract.
|
||||
func seedHistory(t *testing.T, s *Server) {
|
||||
t.Helper()
|
||||
facts := []AdvFact{
|
||||
{GUID: "boss_first:a:1", EventType: "boss_first", Tier: "priority", Actors: []string{"Josie"},
|
||||
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", Region: "the Reach", OccurredAt: 100},
|
||||
{GUID: "boss_kill:b:2", EventType: "boss_kill", Tier: "bulletin", Actors: []string{"Josie"},
|
||||
Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", OccurredAt: 200},
|
||||
{GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Josie"},
|
||||
Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
|
||||
{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
|
||||
Subject: "Josie", Milestone: "level 14", OccurredAt: 400},
|
||||
{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", Actors: []string{"Josie"},
|
||||
Subject: "Josie", Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", OccurredAt: 500},
|
||||
}
|
||||
for _, f := range facts {
|
||||
body, _ := json.Marshal(f)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdventureIngest(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("seed fact %s = %d: %s", f.GUID, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
||||
@@ -134,6 +189,76 @@ func TestWhoSelfUnlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoItemDetail: the item panels carry the facts a player decides on —
|
||||
// descriptions, the engine's effect summary, slot and skill tags — and the worn
|
||||
// set renders with its bond count.
|
||||
func TestWhoItemDetail(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
for _, want := range []string{
|
||||
// "15% damage", not "+15%": html/template escapes a leading + to +.
|
||||
"Worn", "Flame Tongue", "A sword that ignites on command.", "15% damage",
|
||||
"1 of 3 bonds in use", // Flame Tongue is bonded; the Cloak is not
|
||||
"A pick balanced for a long seam.", "mining",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("owner page missing item detail %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoInertOnlyWhenWorn pins the one distinction the panels can get wrong.
|
||||
// "Inert" is a real problem state — the item is on you and doing nothing because
|
||||
// every bond slot is full. An unworn attunement item is not inert, it's just not
|
||||
// worn; gogobee tracks bonds only on equipped rows, so its Attuned is undefined
|
||||
// rather than false. Rendering both the same would invent a problem the player
|
||||
// does not have, on the exact panel they'd use to fix it.
|
||||
func TestWhoInertOnlyWhenWorn(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
// The Cloak is worn with no bond free.
|
||||
if !strings.Contains(body, "inert") {
|
||||
t.Error("a worn item with no bond should be called out as inert")
|
||||
}
|
||||
if strings.Count(body, "inert") != 1 {
|
||||
t.Errorf("only the worn unbonded item is inert, got %d mentions", strings.Count(body, "inert"))
|
||||
}
|
||||
// The backpack Ring of Protection wants a bond but isn't worn.
|
||||
if !strings.Contains(body, "needs a bond") {
|
||||
t.Error("an unworn attunement item should say it needs a bond, not that it is inert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoItemCompare: the compare card that gogobee attaches to a backpack magic
|
||||
// item renders on the owner's page — verdict chip, per-stat deltas, and the name
|
||||
// of the worn item it's measured against — and stays off the public page, since
|
||||
// it rides the owner-private inventory.
|
||||
func TestWhoItemCompare(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
owner := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
for _, want := range []string{
|
||||
"sidegrade", // the verdict chip
|
||||
"vs worn Flame Tongue", // what it's measured against
|
||||
"5% damage", // the better delta ("+5%" — leading + is escaped to +)
|
||||
"-4 HP", // the worse delta (minus is not escaped)
|
||||
"cmp-delta-up", // the gain is coloured as a gain
|
||||
"cmp-delta-down", // the loss as a loss
|
||||
} {
|
||||
if !strings.Contains(owner, want) {
|
||||
t.Errorf("owner compare card missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The compare rides the private inventory, so an anonymous visitor never sees it.
|
||||
anon := getWho(t, s, "tok-josie", "").Body.String()
|
||||
if strings.Contains(anon, "sidegrade") || strings.Contains(anon, "Blazing Brand") {
|
||||
t.Error("compare card leaked onto the public page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
||||
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
||||
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
||||
@@ -197,3 +322,49 @@ func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
||||
t.Error("public page vanished when only the private detail was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoHistoryPanels: the record and the trail render for an adventurer with
|
||||
// facts on file. Driven through the real template and the real ingest path, so a
|
||||
// field slip in either 500s here rather than in prod.
|
||||
func TestWhoHistoryPanels(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
seedHistory(t, s)
|
||||
|
||||
w := getWho(t, s, "tok-josie", "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("who = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"The record",
|
||||
"The Rotmother", // the boss tally
|
||||
"bosses down",
|
||||
"realm-first", // the boss_first got flagged
|
||||
"level 14", // the milestone chip
|
||||
"treasure", // the treasure stat tile
|
||||
"Treasures found", // the showcase panel
|
||||
"Crown of the Drowned King", // the named hoard
|
||||
"first hoard", // the realm-first callout picked it up
|
||||
"The trail",
|
||||
"/adventure/treasure_found:e:5", // the trail links back to the treasure dispatch
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("history render missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoHistoryAbsent: an adventurer with no facts on file gets no empty
|
||||
// scaffolding. Every veteran looks like this the day this ships — their past is
|
||||
// prose in the feed and was never counted — so the blank state has to be a clean
|
||||
// absence, not a wall of zeroes.
|
||||
func TestWhoHistoryAbsent(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
body := getWho(t, s, "tok-josie", "").Body.String()
|
||||
for _, leak := range []string{"The record", "The trail", "bosses down"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("no-history page should not render %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user