adventure: a click-through page for every adventurer on the board
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.
This commit is contained in:
@@ -97,6 +97,10 @@ func runMigrations(d *sql.DB) error {
|
||||
// Occupancy of a shared table. Rows written before the casino went multiplayer
|
||||
// are solo games and read as NULL, which is exactly what they are.
|
||||
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
|
||||
// The public detail sheet (stats + equipped gear) for an adventurer's
|
||||
// click-through page. Rides the roster snapshot; NULL on rows pushed by a
|
||||
// gogobee build that predates the detail page.
|
||||
addColumnIfMissing(d, "adventure_roster", "detail_json", "TEXT")
|
||||
|
||||
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
|
||||
// Check sqlite_master before creating.
|
||||
|
||||
131
internal/storage/detail.go
Normal file
131
internal/storage/detail.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PlayerDetail is one player's private, owner-only expansion — inventory, vault,
|
||||
// house, pets — pushed by gogobee keyed by localpart. Pete stores it in its own
|
||||
// keyspace (player_self_detail) and only ever serves it back to the one
|
||||
// authenticated user it belongs to. Token rides along so the detail page can
|
||||
// prove owner↔page without ever reversing the anonymous roster token.
|
||||
type PlayerDetail struct {
|
||||
Localpart string `json:"localpart"`
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
type HouseView struct {
|
||||
Tier int `json:"tier"`
|
||||
LoanBalance int `json:"loan_balance,omitempty"`
|
||||
Autopay bool `json:"autopay,omitempty"`
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
}
|
||||
|
||||
// PetView is one pet slot.
|
||||
type PetView struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
ArmorTier int `json:"armor_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ReplacePlayerDetail swaps the whole private-detail set in one transaction —
|
||||
// replace, never merge, the same contract as the roster: a player who dropped
|
||||
// out of gogobee's push must lose their stale self-view rather than have it
|
||||
// linger. localpart is lowercased upstream to match how a session Username reads.
|
||||
func ReplacePlayerDetail(players []PlayerDetail, snapshotAt int64) error {
|
||||
tx, err := Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_self_detail`); err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO player_self_detail (localpart, token, detail_json, snapshot_at)
|
||||
VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range players {
|
||||
if p.Localpart == "" || p.Token == "" {
|
||||
continue // a self-view with no owner or no page to hang on is unusable
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stmt.Exec(p.Localpart, p.Token, string(body), snapshotAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PlayerDetailByOwner returns the private detail for localpart, but only when it
|
||||
// owns the given page token. This is the ownership join the detail page needs:
|
||||
// the signed-in user's localpart is trusted (it comes from their verified
|
||||
// session), and a row exists only if gogobee pushed that same (localpart, token)
|
||||
// pair — so a viewer can only ever unlock the self extras on their own page, and
|
||||
// Pete never has to turn a token back into a handle to decide it.
|
||||
func PlayerDetailByOwner(localpart, token string) (PlayerDetail, bool, error) {
|
||||
if localpart == "" || token == "" {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
var storedToken, detailJSON string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token, detail_json FROM player_self_detail WHERE localpart = ?`, localpart).
|
||||
Scan(&storedToken, &detailJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return PlayerDetail{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
if storedToken != token {
|
||||
return PlayerDetail{}, false, nil // signed in, but not the owner of this page
|
||||
}
|
||||
var pd PlayerDetail
|
||||
if err := json.Unmarshal([]byte(detailJSON), &pd); err != nil {
|
||||
return PlayerDetail{}, false, err
|
||||
}
|
||||
pd.Localpart = localpart
|
||||
pd.Token = storedToken
|
||||
return pd, true, nil
|
||||
}
|
||||
|
||||
// SelfToken returns the roster token owned by localpart, if gogobee's last push
|
||||
// carried one. Lets the board mark "your adventurer" without exposing the
|
||||
// localpart↔token map anywhere public.
|
||||
func SelfToken(localpart string) (string, bool) {
|
||||
if localpart == "" {
|
||||
return "", false
|
||||
}
|
||||
var token string
|
||||
err := Get().QueryRow(
|
||||
`SELECT token FROM player_self_detail WHERE localpart = ?`, localpart).Scan(&token)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
126
internal/storage/detail_test.go
Normal file
126
internal/storage/detail_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
// The private self-detail set is the one place Pete holds a localpart↔token
|
||||
// association. These tests pin the security contract of that store: the
|
||||
// ownership join only ever unlocks a page for the localpart that owns it, and a
|
||||
// replace wipes a departed player's stale self-view rather than leaving it to
|
||||
// linger.
|
||||
|
||||
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
|
||||
t.Helper()
|
||||
return PlayerDetail{
|
||||
Localpart: localpart,
|
||||
Token: token,
|
||||
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
|
||||
// only when the signed-in localpart owns the exact page token. A different
|
||||
// localpart, or the owner viewing someone else's page, gets nothing.
|
||||
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatalf("ReplacePlayerDetail: %v", err)
|
||||
}
|
||||
|
||||
// The owner, on their own page: unlocked, and the private goods come through.
|
||||
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
|
||||
}
|
||||
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
|
||||
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
|
||||
}
|
||||
|
||||
// Josie signed in, looking at Quack's page: her localpart doesn't own that
|
||||
// token, so the join must refuse — no peeking at another player's private set.
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
|
||||
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
|
||||
}
|
||||
|
||||
// A signed-in stranger with no self-detail row at all.
|
||||
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
|
||||
t.Error("a stranger unlocked a page they have no row for")
|
||||
}
|
||||
|
||||
// Empty inputs never unlock.
|
||||
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
|
||||
t.Error("empty localpart unlocked a page")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
|
||||
t.Error("empty token unlocked a page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
|
||||
// player gogobee stops pushing (deleted character, dropped out) must lose their
|
||||
// stale self-view, the same complete-snapshot contract as the board.
|
||||
func TestReplacePlayerDetailReplaces(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
seedSelfDetail(t, "quack", "tok-quack"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Next push carries only Josie; Quack has left.
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
|
||||
t.Error("a dropped player's self-view survived the replace")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
|
||||
t.Error("the surviving player lost their self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
|
||||
// (re-derived each push), the ownership check must follow it. The stale token no
|
||||
// longer unlocks; the current one does.
|
||||
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
|
||||
t.Error("the stale token still unlocked the page after a rotation")
|
||||
}
|
||||
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
|
||||
t.Error("the current token failed to unlock the page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
|
||||
// on is dropped at write time — it could never be served and would only be dead
|
||||
// weight.
|
||||
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
if err := ReplacePlayerDetail([]PlayerDetail{
|
||||
{Localpart: "", Token: "tok-orphan"},
|
||||
{Localpart: "ghost", Token: ""},
|
||||
seedSelfDetail(t, "josie", "tok-josie"),
|
||||
}, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
|
||||
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
|
||||
}
|
||||
if _, ok := SelfToken("ghost"); ok {
|
||||
t.Error("a tokenless row was stored")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,10 @@ type RosterEntry struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
IdleHours int `json:"idle_hours,omitempty"`
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
// Detail is the public expanded sheet (stats + gear), carried as raw JSON so
|
||||
// the board path never has to model or touch it — only the detail page decodes
|
||||
// it. Empty when a snapshot predates the detail push.
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
|
||||
@@ -38,16 +43,20 @@ func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
|
||||
}
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO adventure_roster
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, e := range entries {
|
||||
var detail any // NULL when absent, so the column reads as "no detail", not "{}"
|
||||
if len(e.Detail) > 0 {
|
||||
detail = string(e.Detail)
|
||||
}
|
||||
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
|
||||
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -97,18 +106,22 @@ func LoadRoster() ([]RosterEntry, int64, error) {
|
||||
// actually showing, never a stale or guessed token.
|
||||
func RosterEntryByToken(token string) (RosterEntry, bool, error) {
|
||||
var e RosterEntry
|
||||
var detail sql.NullString
|
||||
err := Get().QueryRow(`
|
||||
SELECT token, name, level, COALESCE(class_race, ''), status,
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
|
||||
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at, detail_json
|
||||
FROM adventure_roster WHERE token = ?`, token).Scan(
|
||||
&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt)
|
||||
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt, &detail)
|
||||
if err == sql.ErrNoRows {
|
||||
return RosterEntry{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RosterEntry{}, false, err
|
||||
}
|
||||
if detail.Valid && detail.String != "" {
|
||||
e.Detail = json.RawMessage(detail.String)
|
||||
}
|
||||
return e, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,24 @@ CREATE TABLE IF NOT EXISTS mischief_tiers (
|
||||
ordinal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
||||
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
||||
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
||||
-- like user_euro, it is only ever served back to the one authenticated user it
|
||||
-- belongs to, never on the public board. token is that player's current roster
|
||||
-- token, kept here so the detail page can prove owner↔page by a join without
|
||||
-- ever reversing the one-way token — the association lives only in this
|
||||
-- owner-private table and never reaches any public response. detail_json is the
|
||||
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
||||
-- player who drops out of gogobee's push loses their stale self-view.
|
||||
CREATE TABLE IF NOT EXISTS player_self_detail (
|
||||
localpart TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
snapshot_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
|
||||
@@ -6,11 +6,22 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// buyerLocalpart normalises a session's Authentik username to the Matrix
|
||||
// localpart gogobee keys everything on. Matrix localparts are lowercase; an
|
||||
// Authentik display of the same name may not be, and gogobee pushes balances and
|
||||
// resolves orders under the lowercase form. Lowercasing on both sides is what
|
||||
// keeps the buyer's balance lookup and their order's identity pointing at the
|
||||
// same person.
|
||||
func buyerLocalpart(u *SessionUser) string {
|
||||
return strings.ToLower(strings.TrimSpace(u.Username))
|
||||
}
|
||||
|
||||
// The mischief storefront's web seam.
|
||||
//
|
||||
// Two audiences, two auth models, same file. Browsers hit the OIDC-gated buy
|
||||
@@ -51,7 +62,8 @@ func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||
// A session minted before the storefront existed carries no username, and
|
||||
// without one gogobee can't name the buyer to its ledger. Send them back
|
||||
// through sign-in to mint a fresh one rather than place an unfulfillable order.
|
||||
if u.Username == "" {
|
||||
buyer := buyerLocalpart(u)
|
||||
if buyer == "" {
|
||||
writeMischiefError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
@@ -100,13 +112,13 @@ func (s *Server) handleMischiefOrder(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.InsertMischiefOrder(u.Sub, u.Username, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||
order, err := storage.InsertMischiefOrder(u.Sub, buyer, mark.Token, mark.Name, tier.Key, req.Signed)
|
||||
if err != nil {
|
||||
slog.Error("mischief: insert order", "err", err)
|
||||
writeMischiefError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", u.Username, "tier", tier.Key, "signed", req.Signed)
|
||||
slog.Info("mischief: order placed", "guid", order.GUID, "buyer", buyer, "tier", tier.Key, "signed", req.Signed)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
@@ -159,8 +171,8 @@ func (s *Server) handleMischiefCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
tiers = []storage.MischiefTier{}
|
||||
}
|
||||
resp := mischiefCatalogResp{Tiers: tiers}
|
||||
if u.Username != "" {
|
||||
euro, has, err := storage.UserEuro(u.Username)
|
||||
if lp := buyerLocalpart(u); lp != "" {
|
||||
euro, has, err := storage.UserEuro(lp)
|
||||
if err != nil {
|
||||
slog.Error("mischief: balance", "err", err)
|
||||
} else {
|
||||
|
||||
@@ -134,6 +134,51 @@ func (s *Server) handleRosterIngest(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
|
||||
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
|
||||
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
|
||||
type detailPush struct {
|
||||
SnapshotAt int64 `json:"snapshot_at"`
|
||||
Players []storage.PlayerDetail `json:"players"`
|
||||
}
|
||||
|
||||
// handleDetailIngest replaces the private self-detail set with gogobee's latest
|
||||
// push. Bearer-authed like the roster; the data is owner-private and only ever
|
||||
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
|
||||
func (s *Server) handleDetailIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adv.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// A generous cap: this carries every player's inventory + vault, heavier than
|
||||
// the board, but still a bounded realm — the ceiling only stops a runaway push.
|
||||
var push detailPush
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(push.Players) > rosterMaxEntries {
|
||||
http.Error(w, "detail set too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if push.SnapshotAt <= 0 {
|
||||
push.SnapshotAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
|
||||
slog.Error("detail ingest: replace failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
|
||||
// open tab goes live without a reload. Public — same exposure as the rendered
|
||||
// page, no more.
|
||||
|
||||
@@ -102,7 +102,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
shared []string
|
||||
pages []string
|
||||
}{
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
|
||||
{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
|
||||
}
|
||||
tpls := make(map[string]*template.Template)
|
||||
@@ -219,6 +219,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
|
||||
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
|
||||
|
||||
// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
|
||||
// ingest like the roster; there is no public read — it is only ever served
|
||||
// back to its owner, inline on the detail page below.
|
||||
mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)
|
||||
|
||||
// One adventurer's detail page and its live re-poll. Both public (stats +
|
||||
// gear); the page adds the owner's private extras when the signed-in viewer
|
||||
// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
|
||||
// or /adventure/art/{type}.
|
||||
mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
|
||||
mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)
|
||||
|
||||
// Per-dispatch permalink (the article_url every ingested story points at).
|
||||
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
|
||||
// channel listing, registered in the channels loop above).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@
|
||||
{{range .Roster}}
|
||||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||||
<span class="font-semibold">{{.Name}}</span>
|
||||
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||||
@@ -107,7 +107,7 @@
|
||||
: '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||||
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
|
||||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||
|
||||
197
internal/web/templates/who.html
Normal file
197
internal/web/templates/who.html
Normal file
@@ -0,0 +1,197 @@
|
||||
{{define "title"}}{{.Mark.Name}} — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
|
||||
<nav class="mb-4">
|
||||
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
|
||||
<span aria-hidden="true">←</span> The board
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{if .Mark.OnRun}}⚔{{else}}🏠{{end}}</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">adventurer</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Mark.Name}}</h1>
|
||||
<p class="mt-2 opacity-90">Level {{.Mark.Level}} {{.Mark.ClassRace}}</p>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75" id="who-where">
|
||||
{{if .Mark.OnRun}}{{.Mark.Where}}{{else}}In town{{if .Mark.Idle}} · {{.Mark.Idle}}{{end}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .HasDetail}}
|
||||
<!-- Stats + gear: public, the same anonymity model as the board. -->
|
||||
<section class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
|
||||
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-baseline justify-between">
|
||||
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
|
||||
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-3 gap-2">
|
||||
{{range .Abilities}}
|
||||
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
|
||||
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
|
||||
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
|
||||
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Mark.OnRun}}
|
||||
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
|
||||
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
|
||||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
|
||||
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
|
||||
{{if .Detail.Gear}}
|
||||
<ul class="space-y-2">
|
||||
{{range .Detail.Gear}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
|
||||
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork">★</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasSelf}}
|
||||
<!-- Owner-only: this is you. Inventory, vault, house, pets — private, served
|
||||
only because your signed-in localpart owns this page's token. -->
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
|
||||
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
|
||||
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
|
||||
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||||
{{if .Self.Pets}}
|
||||
<ul class="space-y-2 text-sm">
|
||||
{{range .Self.Pets}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-4">Backpack</h2>
|
||||
{{if .Self.Inventory}}
|
||||
<ul class="space-y-1.5 text-sm max-h-72 overflow-y-auto pr-1">
|
||||
{{range .Self.Inventory}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Self.Vault}}
|
||||
<h3 class="font-display text-lg font-bold mt-6 mb-3">Vault</h3>
|
||||
<ul class="space-y-1.5 text-sm max-h-52 overflow-y-auto pr-1">
|
||||
{{range .Self.Vault}}
|
||||
<li class="flex items-baseline justify-between gap-3">
|
||||
<span class="flex-1">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Value}} · {{.Value}}g{{end}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<script>
|
||||
// The sheet is state, like the board: keep an open tab honest without a reload.
|
||||
// Public detail only (HP, room, supplies) — the private panels stay as rendered.
|
||||
(function () {
|
||||
var root = document.getElementById('who');
|
||||
if (!root) return;
|
||||
var token = root.getAttribute('data-token');
|
||||
|
||||
function setBar() {
|
||||
var el = document.getElementById('who-hp-bar');
|
||||
var hp = document.getElementById('who-hp');
|
||||
if (!el || !hp) return;
|
||||
var m = hp.textContent.match(/(-?\d+)\s*\/\s*(\d+)/);
|
||||
if (!m) return;
|
||||
var cur = parseInt(m[1], 10), max = parseInt(m[2], 10);
|
||||
var pct = max > 0 ? Math.max(0, Math.min(100, Math.round(cur / max * 100))) : 0;
|
||||
el.style.width = pct + '%';
|
||||
}
|
||||
setBar();
|
||||
|
||||
function txt(id, v) { var el = document.getElementById(id); if (el && v != null) el.textContent = v; }
|
||||
|
||||
function refresh() {
|
||||
fetch('/api/adventure/who/' + encodeURIComponent(token), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
if (!data.live) {
|
||||
txt('who-where', 'Back in town');
|
||||
return;
|
||||
}
|
||||
var d = data.detail || {};
|
||||
var mark = data.mark || {};
|
||||
if (data.has_detail) {
|
||||
var temp = d.temp_hp ? ' +' + d.temp_hp : '';
|
||||
txt('who-hp', d.hp_current + ' / ' + d.hp_max + temp);
|
||||
txt('who-ac', d.armor_class);
|
||||
txt('who-room', d.room);
|
||||
txt('who-supplies', d.supplies);
|
||||
txt('who-threat', d.threat_level);
|
||||
setBar();
|
||||
}
|
||||
if (mark.OnRun) txt('who-where', mark.Where);
|
||||
})
|
||||
.catch(function () { /* transient — next tick will do */ });
|
||||
}
|
||||
setInterval(refresh, 60000);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
169
internal/web/who.go
Normal file
169
internal/web/who.go
Normal file
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
199
internal/web/who_test.go
Normal file
199
internal/web/who_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The adventurer detail page. Two contracts under test: the public sheet reaches
|
||||
// any visitor (stats + gear, never a handle), and the private self-view unlocks
|
||||
// only for the signed-in owner of that exact page — anon and other users see the
|
||||
// public page and nothing more.
|
||||
|
||||
func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(push)
|
||||
req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDetailIngest(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
|
||||
// the roster push carries it.
|
||||
func publicDetail(t *testing.T) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"hp_current": 30,
|
||||
"hp_max": 42,
|
||||
"temp_hp": 5,
|
||||
"armor_class": 17,
|
||||
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||
"gear": []map[string]any{
|
||||
{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
|
||||
{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
|
||||
},
|
||||
"supplies": 8,
|
||||
"threat_level": 2,
|
||||
"room": "3 / 7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// seedWho puts one adventurer on the board with a public detail sheet, and
|
||||
// pushes a private self-detail set owned by `owner` for that same token.
|
||||
func seedWho(t *testing.T, owner string) *Server {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
e.Level = 14
|
||||
e.ClassRace = "human cleric"
|
||||
e.Detail = publicDetail(t)
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner,
|
||||
Token: "tok-josie",
|
||||
Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
|
||||
Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
|
||||
House: storage.HouseView{Tier: 2, LoanBalance: 1500},
|
||||
Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
|
||||
req.SetPathValue("token", token)
|
||||
if asUser != "" {
|
||||
payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdventureWho(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
|
||||
// through the real template, so a field slip 500s here rather than in prod.
|
||||
func TestWhoPublicSheet(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "") // anonymous
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("public page missing %q", want)
|
||||
}
|
||||
}
|
||||
// The private goods must NOT be on an anonymous render.
|
||||
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("anonymous page leaked private detail %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
|
||||
// inventory/vault/house/pets inline.
|
||||
func TestWhoSelfUnlock(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "josie")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("owner GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("owner page missing private detail %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
|
||||
// username; the localpart it maps to is lowercase. The owner must still unlock.
|
||||
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "Josie") // capital-J session
|
||||
if !strings.Contains(w.Body.String(), "Iron Ore") {
|
||||
t.Error("a mixed-case owner username failed to unlock their own self-view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
|
||||
// sheet on someone else's page — the ownership join must not leak across users.
|
||||
func TestWhoOtherUserNoUnlock(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("other-user GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Josie") {
|
||||
t.Error("public sheet missing for a signed-in non-owner")
|
||||
}
|
||||
for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Errorf("a non-owner unlocked private detail %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
|
||||
// nothing — the same liveness gate the storefront uses, so a stale or guessed
|
||||
// token never renders a page.
|
||||
func TestWhoOffBoardIs404(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
|
||||
t.Errorf("off-board token = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
|
||||
// set whole, so a player dropped from a later push loses their self-view.
|
||||
func TestDetailIngestReplacesAndAuth(t *testing.T) {
|
||||
s := seedWho(t, "josie")
|
||||
|
||||
if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
|
||||
t.Errorf("bad bearer = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// A later push without Josie: her self-view must be gone, but the public
|
||||
// board (a separate keyspace) is untouched, so her page still renders public.
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
|
||||
t.Fatalf("empty detail push = %d, want 200", w.Code)
|
||||
}
|
||||
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
if strings.Contains(body, "Iron Ore") {
|
||||
t.Error("owner still unlocked after her detail was dropped from the push")
|
||||
}
|
||||
if !strings.Contains(body, "Josie") {
|
||||
t.Error("public page vanished when only the private detail was dropped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user