Files
Pete/internal/web/who.go
prosolis 6219224ea9 adventure: count the treasures an adventurer has actually found
A treasure_found fact turns loot into a trophy: a story-grade find lands on the
who page as a stat tile, a named row in a Treasures found showcase, and a trail
entry linking back to the dispatch. A realm-first hoard rides the priority tier
the way zone_first does, and gets the same star and callout.

The item name travels in the fact's stakes field, which the events log now keeps
(a new nullable column, backfilled to NULL for older rows). Counting is only ever
from the fact, never the vault snapshot, so a bought sword is never a trophy and a
history that predates the fact is a clean zero.

This is a new event_type, so Pete's ingest must be deployed before gogobee emits
one, or the first finds park forever on the retry ladder.
2026-07-17 09:02:19 -07:00

358 lines
12 KiB
Go

package web
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The adventurer detail page.
//
// A click-through from the live board: anyone may see a mark's current stats and
// equipped gear (the same anonymity model as the board — a character name and a
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
// with their private inventory, vault, house, and pets.
//
// Both halves are still gogobee → Pete: the public detail rides the roster
// snapshot, the private detail rides its own push. Pete renders what it was
// given; it never reaches back into the game box.
// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
type whoDetail struct {
HPCurrent int `json:"hp_current"`
HPMax int `json:"hp_max"`
TempHP int `json:"temp_hp"`
ArmorClass int `json:"armor_class"`
Abilities [6]int `json:"abilities"`
Modifiers [6]int `json:"modifiers"`
Gear []whoGear `json:"gear"`
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 {
Slot string `json:"slot"`
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork"`
}
// abilityRow is one ability line, pre-formatted for the template.
type abilityRow struct {
Label string
Score int
ModStr string // "+2", "-1"
}
var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}
type whoPage struct {
pageData
Mark RosterView
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.
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
slog.Error("who: roster lookup failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // names a player character; keep out of search indexes
page := whoPage{
pageData: base,
Mark: toRosterView(entry),
}
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
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, events)
}
// Owner enrichment: only when the signed-in user's localpart owns this exact
// page token. The ownership is proven by a row gogobee pushed, never by
// reversing the token, so no visitor can unlock another player's self extras.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
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++
}
}
}
}
}
s.render(w, "who", page)
}
// handleAdventureWhoAPI serves the public detail as JSON for the page's live
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
// same exposure as the rendered page. The private self extras are not included:
// inventory changes rarely and stays server-rendered behind the ownership check.
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
token := r.PathValue("token")
entry, ok, err := storage.RosterEntryByToken(token)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if !ok {
// Gone from the board (expedition ended, opted out): tell the poller so it
// can stop, rather than 404-ing an open tab into an error.
_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
return
}
d, abil, hasDetail := decodeWhoDetail(entry.Detail)
_ = json.NewEncoder(w).Encode(map[string]any{
"live": true,
"mark": toRosterView(entry),
"has_detail": hasDetail,
"detail": d,
"abilities": abil,
})
}
// 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, 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(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(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":
if e.Opponent != "" {
return "vs " + e.Opponent
}
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.
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
if len(raw) == 0 {
return whoDetail{}, nil, false
}
var d whoDetail
if err := json.Unmarshal(raw, &d); err != nil {
return whoDetail{}, nil, false
}
rows := make([]abilityRow, 0, 6)
for i := 0; i < 6; i++ {
rows = append(rows, abilityRow{
Label: abilityLabels[i],
Score: d.Abilities[i],
ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
})
}
return d, rows, true
}