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"` } 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 HasSelf bool Self storage.PlayerDetail // 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 } // 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 } // 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 } } } 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), Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death", }) } 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 } 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 }