The extract pre-check is gone. It read a snapshot up to two minutes behind and still got the last word, so somebody who set out over Matrix during a lagging roster push was told they weren't on an expedition for a run gogobee would happily have ended. Same call abandon and leave already made: let it through and let rejected_not_running be the answer. The siege_join check stays, because whether a boss is camped outside town is town-wide and runs on a day-or-longer clock, but it now reads one column through SiegeIsCamped instead of loading every defender row and the whole history to look at one flag. The war-room history insert is OR REPLACE. boss_id is the primary key and it was never settled whether gogobee means the siege instance or the boss type by it, so a duplicate pair used to fail the transaction carrying the live boss and the muster too and freeze the war room on the last good snapshot. A dropped history row is the smaller failure; the open question is noted in the schema. offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old to push seats sends a valid blob with no party key, which decodes to the same empty slice as a solo run, and a party member got shown the button that throws away everyone's day. That needs a new field, so whoDetail gains party_known and the flag gates the empty-list branch alone; the branch that reads the viewer's own seat is self-evidencing and keeps working against any sender. gogobee's half is written up in adventure_party_known_flag.md. And an empty offer list no longer claims "you're already out there", which Pete can't actually know from a game box too old to push offers at all.
561 lines
22 KiB
Go
561 lines
22 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"`
|
|
// PartyKnown says the sheet was built by a gogobee that knows about party
|
|
// seats, which an absent Party cannot: nil means "solo" and "this sender never
|
|
// pushes seats" identically, and those two want opposite buttons. It is a
|
|
// capability flag about the SENDER, set unconditionally by any gogobee new
|
|
// enough — including on a solo run, including when the seat list is omitted —
|
|
// so it must never be read as a fact about the character.
|
|
PartyKnown bool `json:"party_known"`
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Leadership is legible in the party seats gogobee pushes (W7) and the sitter's
|
|
// standing is in the babysit offer (W5b), so the facts the buttons need are on
|
|
// the wire already — except for one, which nil could not express. partyKnown is
|
|
// the sender's "I know about party seats" flag, and it gates the empty-list
|
|
// branch alone. An empty list means "solo, so this player is the leader" ONLY
|
|
// from a sender that would have listed seats if there were any; from a blob that
|
|
// did not decode, or from a gogobee too old to push seats at all, the same empty
|
|
// slice 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.
|
|
//
|
|
// The asymmetry is deliberate: the len(party) > 0 branch reads the viewer's own
|
|
// seat and is self-evidencing — a seat that says "member" cannot be mistaken for
|
|
// leadership — so it needs no flag and keeps working against every sender.
|
|
func offersToUndo(token, status string, partyKnown 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 {
|
|
if len(party) == 0 {
|
|
// A SOLO run publishes no party at all — partySeatViews returns nil
|
|
// below two seats — so from a sender that knows about seats an empty list
|
|
// is not "we don't know", it is "there is nobody else", which makes this
|
|
// player the leader. Without the flag it is exactly "we don't know", and
|
|
// the button stays off.
|
|
abandon = partyKnown
|
|
} 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.PartyKnown, 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
|
|
}
|