package web import ( "encoding/json" "fmt" "log/slog" "net/http" "strconv" "strings" "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"` // Party is who else is down there, leader first. Absent on a solo run. Party []partySeat `json:"party"` } // partySeat is one body on a shared expedition as gogobee described it. Kind is // "leader", "member" or "companion" — the game keeps those three carefully // distinct and so does this page: a companion fights but is nobody's account, so // he is named without a link and never counted as a player. // // A seat with a Kind but no Name is an opted-out player, kept on purpose. gogobee // anonymises rather than deletes here (the Siege contributor rule, not the realm // occupant rule), because a party of three rendered as a pair contradicts the // supply burn and threat level printed beside it. Render it as an unnamed seat; // never as an absent one, and never with a link. type partySeat struct { Kind string `json:"kind"` Name string `json:"name"` Token string `json:"token"` Level int `json:"level"` } // Anonymous reports whether this seat belongs to a player who has opted out of // the news. Keyed on the name rather than the token because the two come apart // only in the direction that matters: gogobee never sends a token without a name. func (p partySeat) Anonymous() bool { return p.Kind != "companion" && p.Name == "" } // petRow is one pet with its levelling made legible. gogobee sends centi-XP and // the engine's own threshold for the pet's current level band; the arithmetic // here is presentation only — a percentage for the bar and a decimal for the // label — and there is deliberately no copy of the curve on this side. type petRow struct { storage.PetView // Capped is the level ceiling: gogobee reports 0 needed, which is not the same // as an empty bar and must not render as one. Capped bool // Percent is 0-100 for the bar's width. Clamped, because a pet can sit above // its own threshold for the moment between earning XP and the next level-up // pass, and a bar wider than its track breaks the layout rather than the maths. Percent int // Progress is the human label: "7.5 / 20". Progress string // NextLevel is what the bar is filling toward. 0 when capped. NextLevel int } // petRows makes the pushed pets renderable. Pets are owner-only — the public // sheet has never carried them — so this runs behind the ownership join. func petRows(pets []storage.PetView) []petRow { if len(pets) == 0 { return nil } out := make([]petRow, 0, len(pets)) for _, p := range pets { row := petRow{PetView: p, Capped: p.XPNeeded <= 0} if !row.Capped { row.Percent = min(100, max(0, p.XP*100/p.XPNeeded)) row.Progress = fmt.Sprintf("%s / %s", centiXP(p.XP), centiXP(p.XPNeeded)) row.NextLevel = p.Level + 1 } out = append(out, row) } return out } // centiXP renders the game's hundredths as the number a player recognises. A pet // earns 1.5 XP per action, so the halves are real and dropping them would make a // bar that visibly moved report the same figure twice. func centiXP(centi int) string { if centi%100 == 0 { return strconv.Itoa(centi / 100) } return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(centi)/100), "0"), ".") } // rosterStatusExpedition is the one roster status that means "down there right // now". Spelled out here because W9's offers turn on it and a typo would silently // hide a button rather than fail. const rosterStatusExpedition = "expedition" // offersToUndo decides which of the three W9 verbs this owner's page proposes. // Courtesy only, like every offer on this page: gogobee re-resolves all three and // its refusal is the real answer. What this buys is a page that does not put // "Leave the party" in front of somebody standing in town. // // Nothing new crosses the wire for it. Leadership is already legible in the party // seats gogobee pushes (W7), and the sitter's standing is already in the babysit // offer (W5b) — so the two facts the buttons need were both already here. // haveParty says whether the public detail blob decoded at all, and it is // load-bearing rather than defensive. An empty seat list means "solo" ONLY when // we have actually read the sheet; a blob that did not decode — a gogobee too old // to push seats, a truncated column, a shape change — produces the same empty // slice, and treating that as solo would offer a party MEMBER the button that // throws away everyone's day. Found by getting a fixture wrong: the page did // exactly that, silently and convincingly. func offersToUndo(token, status string, haveParty bool, party []partySeat, self storage.PlayerDetail) (abandon, leave, cancelSitter bool) { // The sitter first, because it is the only one of the three whose fact does // not go stale: an engagement is a property of the character, not of where // they are standing, so a two-minute-old snapshot is still right about it. cancelSitter = self.Babysit != nil && self.Babysit.Active if status == rosterStatusExpedition && haveParty { if len(party) == 0 { // A SOLO run publishes no party at all — partySeatViews returns nil // below two seats — so an empty list here is not "we don't know", it is // "there is nobody else", which makes this player the leader. abandon = true } else { // With a party, offer strictly on the viewer's own seat, and offer // nothing at all if we cannot find it. Guessing in that case would mean // showing a member the button that throws away everyone's day. for _, seat := range party { if seat.Token != token { continue } switch seat.Kind { case "leader": abandon = true case "member": leave = true } break } } } // An extracted expedition is still the owner's to close, and it is the case // the status check above cannot see: its owner reads as idle in town, with no // party, and the resume offer is the only sign the run is still open. Without // this, a leader who wanted out had to pay to walk back in first — the exact // hole the game's own abandon path was widened to cover. // // Not while they are sitting in somebody ELSE'S party, though, and that is not // a hypothetical: a player who extracted their own run and then took a seat // has both facts true at once, about two different expeditions. Both buttons // on one page would be asking the reader to work out which run each meant, // and this page is about the one they are standing in. The abandon is still // there from town the moment they walk out of the party. if self.Resume != nil && !leave { abandon = true } return abandon, leave, cancelSitter } // 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 // RunLog is the liveblog for the run the map is showing: what happened in // those rooms, in order. Deliberately independent of MapView — the map rides // the roster snapshot and the log rides the beat channel, so either can be // present without the other and the page must not assume they arrive together. RunLog RunLogView HasSelf bool Self storage.PlayerDetail // The three W9 verbs that undo something. Unlike every offer above them these // are derived on Pete rather than pushed: leadership is already legible in the // party seats, and a sitter's standing is already in the babysit offer, so // there was nothing to add to the wire. See offersToUndo. CanAbandon bool CanLeave bool CanCancelSitter bool // 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 PetRows []petRow 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) } page.RunLog = runLogFor(token) // 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 // 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 { // A signed-in visitor viewing someone else's page legitimately fails the // ownership join (ok=false), so that alone is not worth a log. Two other // misses are: a genuine lookup/decode error, and a session that carries no // username (minted before the game economy existed) — for the latter the // join can never match because buyerLocalpart is empty. Both used to fail // silently here; surface them so a stuck owner is diagnosable. lp := buyerLocalpart(u) if lp == "" { slog.Warn("who: signed-in session has no username; owner unlock skipped", "sub", u.Sub) } else if self, ok, err := storage.PlayerDetailByOwner(lp, token); err != nil { slog.Error("who: owner detail lookup failed", "localpart", lp, "token", token, "err", err) } else if 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") page.PetRows = petRows(self.Pets) for _, it := range self.Equipped { if it.Attuned { page.BondsUsed++ } } page.CanAbandon, page.CanLeave, page.CanCancelSitter = offersToUndo(token, entry.Status, page.HasDetail, page.Detail.Party, 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: tell the poller so it can stop, rather than 404-ing // an open tab into an error. // // No run log here, deliberately. Finishing a run does NOT take an // adventurer off the board — they stay on it as idle, and their last log // keeps rendering through this endpoint's normal path. What DOES take them // off it is opting out or being removed, and shipping a room-by-room // account of where somebody is from the one branch that means "this player // asked not to be listed" would make the API say what the page refuses to. _ = 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, // The liveblog rides the sheet's poll rather than a second timer: the two // move on the same 2-minute push and a separate request would just be a // second way to be out of step with the map beside it. "run_log": runLogFor(token), }) } // 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. 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 }