Four small surfaces the game has had all along and the web has never shown. The party roster on the adventurer page is the one with a bug behind it. The board resolved an expedition by owner id, so a player seated on somebody else's run has been reading as "idle in town" while standing in a tier-4 dungeon. Seats now ride the roster detail beside the supply and threat numbers, and an opted-out player's seat is anonymised rather than dropped: a party of three rendered as a pair contradicts everything printed next to it. Pets show their levelling. They have earned XP from every won fight since that wiring was fixed and the only place a level ever appeared was a Matrix line that scrolled away. The threshold comes from the engine, in the engine's own centi-XP, because a copy of the curve here would drift the first time a band moved. "While you were away" is the one panel on the site about the reader rather than the realm. Two stamps behind it, not one: a single column would show the news, move the clock, and render an empty box over the same events on the reader's first refresh. And the story permalink names its region, which has been hardcoded empty behind a `// reserved` comment since the page shipped.
470 lines
18 KiB
Go
470 lines
18 KiB
Go
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"), ".")
|
|
}
|
|
|
|
// 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 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++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|