Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"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
|
|
Stale bool
|
|
HasDetail bool
|
|
Detail whoDetail
|
|
Abilities []abilityRow
|
|
HasSelf bool
|
|
Self storage.PlayerDetail
|
|
}
|
|
|
|
// 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),
|
|
Stale: storage.RosterSnapshotAt() == 0,
|
|
}
|
|
if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
|
|
page.HasDetail = true
|
|
page.Detail = d
|
|
page.Abilities = abil
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|