Three verbs to match gogobee's: call off an expedition, turn back out of somebody else's party, send the pet sitter home. Which one this page offers is derived here rather than pushed — leadership is already legible in the party seats and the sitter's standing is already in the babysit offer, so nothing new crosses the wire. Two things running it turned up that no test would have. An applied abandon left "Pull out of the run" sitting under a verdict saying the expedition was over, so an applied verb now also hides the other verbs it just made untrue. And a party member was being offered that same button in the first place, beside the one that actually works — Pete knows from the seat it just read that gogobee would refuse it, so it is withheld. Also: heal the Matrix handle onto push rows stored before the column existed, on its own endpoint rather than through the subscribe upsert, which resets both watermarks and would have silenced the digest for anybody who reads the site regularly. And stack the board row below sm — four flex columns that wrapped to six lines on a phone, pre-existing.
548 lines
21 KiB
Go
548 lines
21 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"), ".")
|
|
}
|
|
|
|
// 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
|
|
}
|