adventure: show the party, the pets, and what you missed
Four small surfaces the game has had all along and the web has never shown. The party roster on the adventurer page is the one with a bug behind it. The board resolved an expedition by owner id, so a player seated on somebody else's run has been reading as "idle in town" while standing in a tier-4 dungeon. Seats now ride the roster detail beside the supply and threat numbers, and an opted-out player's seat is anonymised rather than dropped: a party of three rendered as a pair contradicts everything printed next to it. Pets show their levelling. They have earned XP from every won fight since that wiring was fixed and the only place a level ever appeared was a Matrix line that scrolled away. The threshold comes from the engine, in the engine's own centi-XP, because a copy of the curve here would drift the first time a band moved. "While you were away" is the one panel on the site about the reader rather than the realm. Two stamps behind it, not one: a single column would show the news, move the clock, and render an empty box over the same events on the reader's first refresh. And the story permalink names its region, which has been hardcoded empty behind a `// reserved` comment since the page shipped.
This commit is contained in:
@@ -412,11 +412,18 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
// the three end-of-expedition types carry a run id at all, and the run behind
|
||||
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||||
// did before the report existed.
|
||||
runReport := ""
|
||||
// Region rides the same lookup rather than a second query. It is a *fact*
|
||||
// field, never on the story row — the story is the words Pete wrote and they
|
||||
// have no columns for where. So a dispatch filed before the fact table existed
|
||||
// still renders regionless, which is what it always did.
|
||||
runReport, region := "", ""
|
||||
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||||
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||||
} else {
|
||||
runReport = runReportLinkFor(ev)
|
||||
if ev != nil {
|
||||
region = ev.Region
|
||||
}
|
||||
}
|
||||
|
||||
base := s.base(r)
|
||||
@@ -431,7 +438,7 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
||||
Emoji: emoji,
|
||||
Headline: st.Headline,
|
||||
Body: body,
|
||||
Region: "", // reserved: region isn't stored on the row yet
|
||||
Region: region,
|
||||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||
Permalink: s.advPermalink(guid),
|
||||
RunReportURL: runReport,
|
||||
|
||||
@@ -551,3 +551,52 @@ func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
||||
t.Error("fact-guard rejection was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermalinkNamesTheRegion. Region is a fact field, never a story column — the
|
||||
// story is Pete's words and they have no place for a where — so the permalink has
|
||||
// to read it off the fact row it already loads for the run-report link. It was
|
||||
// hardcoded empty with a `// reserved` comment for the whole life of the page.
|
||||
//
|
||||
// The second half matters as much: a multi-region zone is the only place a region
|
||||
// exists, so a dispatch without one must not print an empty separator.
|
||||
func TestPermalinkNamesTheRegion(t *testing.T) {
|
||||
const token = "t"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
withRegion := AdvFact{
|
||||
GUID: "zone_clear:reg:1000", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Region: "the Cinder Reach", Level: 14, OccurredAt: 1000,
|
||||
}
|
||||
without := AdvFact{
|
||||
GUID: "zone_clear:noreg:1001", EventType: "zone_clear", Tier: "bulletin",
|
||||
Actors: []string{"Brannigan"}, Subject: "Brannigan",
|
||||
Zone: "the Underforge", Level: 14, OccurredAt: 1001,
|
||||
}
|
||||
for _, f := range []AdvFact{withRegion, without} {
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("ingest %s = %d", f.GUID, rw.Code)
|
||||
}
|
||||
}
|
||||
|
||||
story := func(guid string) string {
|
||||
req := httptest.NewRequest("GET", "/adventure/"+guid, nil)
|
||||
req.SetPathValue("guid", guid)
|
||||
rw := httptest.NewRecorder()
|
||||
s.handleAdventureStory(rw, req)
|
||||
if rw.Code != 200 {
|
||||
t.Fatalf("permalink %s = %d", guid, rw.Code)
|
||||
}
|
||||
return rw.Body.String()
|
||||
}
|
||||
|
||||
if !strings.Contains(story(withRegion.GUID), "the Cinder Reach") {
|
||||
t.Error("permalink does not name the region the fact carries")
|
||||
}
|
||||
// The template joins the region on with " · "; a regionless dispatch must not
|
||||
// render a dangling one.
|
||||
if body := story(without.GUID); strings.Contains(body, "Reported ") &&
|
||||
strings.Contains(body, " · </p>") {
|
||||
t.Error("a dispatch with no region printed an empty separator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// "While you were away" — the one panel on the site that is about the reader.
|
||||
//
|
||||
// Everything else in the adventure section is the realm's news: the board, the
|
||||
// Siege, the standings. This is the owner's own adventurer, and only what has
|
||||
// happened to them since they last looked. It pairs with W6's push alerts and
|
||||
// covers the gap those deliberately leave: the alerts are four opt-in categories
|
||||
// chosen for being worth interrupting somebody over, while this catches
|
||||
// everything, for people who would rather not be interrupted at all.
|
||||
//
|
||||
// It renders on /adventure page 1 only. The panel is present tense and page 2 of
|
||||
// an archive is not where anybody looks for what just happened, which is the same
|
||||
// rule the roster and the Siege strip already follow.
|
||||
|
||||
// awayCap bounds the panel. Six lines is a glance; a longer list is the trail on
|
||||
// the adventurer's own page, which is where the "all of it" link goes.
|
||||
const awayCap = 6
|
||||
|
||||
// awayView is the panel. Has is false in every case where there is nothing
|
||||
// honest to show — not signed in, no adventurer, first ever visit, or simply
|
||||
// nothing new — and the template renders nothing at all rather than an empty box
|
||||
// announcing that nothing happened.
|
||||
type awayView struct {
|
||||
Has bool
|
||||
Name string // the reader's own character
|
||||
Since string // "3 hours", "2 days" — how long they were gone
|
||||
Lines []awayLine
|
||||
// HasMore says there is more than the cap, without saying how much more. The
|
||||
// window query reads one row past the cap to learn this; an exact count would
|
||||
// need a second query over the same window to tell somebody a number they are
|
||||
// about to click past anyway.
|
||||
HasMore bool
|
||||
Token string // their adventurer page, where the rest of the trail is
|
||||
}
|
||||
|
||||
// awayLine is one thing that happened, in the trail's own shape. Built from the
|
||||
// fact rather than from the dispatch headline for the same reason buildTimeline
|
||||
// is: a headline is a news sentence written to be shouted once, and six of them
|
||||
// stacked in a panel read as shouting.
|
||||
type awayLine struct {
|
||||
Emoji string
|
||||
Label string
|
||||
Line string
|
||||
When string // relative: this panel is about recency
|
||||
Permalink string
|
||||
Notable bool
|
||||
}
|
||||
|
||||
// awayPanel builds the panel for whoever is asking, and stamps their visit.
|
||||
//
|
||||
// The stamp is written even when the panel comes back empty — even for a signed-in
|
||||
// user with no adventurer at all — and that is deliberate: a clock that only
|
||||
// advances when there is something to show would hand somebody their entire
|
||||
// backlog on the day they finally rolled a character.
|
||||
func (s *Server) awayPanel(r *http.Request) awayView {
|
||||
if s.auth == nil {
|
||||
return awayView{}
|
||||
}
|
||||
u := s.auth.userFromRequest(r)
|
||||
if u == nil {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
from, first, err := storage.AdvVisitWindow(u.Sub, now)
|
||||
if err != nil {
|
||||
slog.Error("away: visit clock failed", "sub", u.Sub, "err", err)
|
||||
return awayView{}
|
||||
}
|
||||
if first {
|
||||
// Never seen before. Their history is not news to them, and a first visit
|
||||
// greeted by every death their character ever suffered is a worse welcome
|
||||
// than no panel at all.
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
// The ownership join, re-read on every request rather than cached anywhere —
|
||||
// same discipline as the alert sender and the run report link. It fails closed
|
||||
// on an opt-out and on a player gogobee has stopped pushing, both of which mean
|
||||
// Pete cannot honestly say which adventurer is this reader's.
|
||||
lp := buyerLocalpart(u)
|
||||
if lp == "" {
|
||||
return awayView{}
|
||||
}
|
||||
name, ok := storage.AdvCharacterForOwner(lp)
|
||||
if !ok {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
// One extra row is fetched past the cap purely to answer "is there more",
|
||||
// without a second COUNT query over the same window.
|
||||
events, err := storage.EventsBySubjectSince(name, from, awayCap+1)
|
||||
if err != nil {
|
||||
slog.Error("away: dispatch lookup failed", "subject", name, "err", err)
|
||||
return awayView{}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return awayView{}
|
||||
}
|
||||
|
||||
v := awayView{Has: true, Name: name, Since: awaySince(now - from)}
|
||||
if token, ok := storage.SelfToken(lp); ok {
|
||||
v.Token = token
|
||||
}
|
||||
if len(events) > awayCap {
|
||||
v.HasMore = true
|
||||
events = events[:awayCap]
|
||||
}
|
||||
for _, e := range events {
|
||||
label, emoji := advEventMeta(e.EventType)
|
||||
v.Lines = append(v.Lines, awayLine{
|
||||
Emoji: emoji,
|
||||
Label: label,
|
||||
Line: timelineLine(name, e),
|
||||
When: awayAgo(now - e.OccurredAt),
|
||||
Permalink: s.advPermalink(e.GUID),
|
||||
Notable: e.EventType == "boss_first" || e.EventType == "zone_first" ||
|
||||
e.EventType == "death",
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// awaySince phrases the gap the panel covers. Rounded down, and it never claims
|
||||
// less than an hour: the window is at least one session gap wide, and "since 34
|
||||
// minutes ago" is a precision the clock behind it does not have.
|
||||
func awaySince(secs int64) string {
|
||||
switch d := time.Duration(secs) * time.Second; {
|
||||
case d < 2*time.Hour:
|
||||
return "an hour"
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||
case d < 14*24*time.Hour:
|
||||
return fmt.Sprintf("%d days", int(d.Hours())/24)
|
||||
default:
|
||||
return "a while"
|
||||
}
|
||||
}
|
||||
|
||||
// awayAgo is a compact relative stamp for one line. Deliberately not the trail's
|
||||
// "Jan 2, 2006": everything in this panel is recent by construction, and a date
|
||||
// on it would make the reader do the subtraction themselves.
|
||||
func awayAgo(secs int64) string {
|
||||
switch d := time.Duration(secs) * time.Second; {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The "while you were away" panel. Two things are worth pinning: it never shows
|
||||
// somebody else's adventurer, and its window survives a page refresh — the
|
||||
// failure that would make the whole panel useless without breaking anything a
|
||||
// unit test would normally notice.
|
||||
|
||||
// awayReq builds a request for /adventure as a signed-in user, or anonymously
|
||||
// when sub is empty.
|
||||
func awayReq(t *testing.T, s *Server, sub, username string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/adventure", nil)
|
||||
if sub != "" {
|
||||
payload, _ := json.Marshal(SessionUser{
|
||||
Sub: sub, Username: username, Exp: time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// seedAwayOwner puts one adventurer on the board owned by localpart, the way the
|
||||
// two real pushes do.
|
||||
func seedAwayOwner(t *testing.T, localpart, character string) {
|
||||
t.Helper()
|
||||
now := time.Now().Unix()
|
||||
if err := storage.ReplaceRoster([]storage.RosterEntry{{
|
||||
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
|
||||
}}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
|
||||
Localpart: localpart, Token: "tok-" + localpart,
|
||||
}}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedAwayEvent(t *testing.T, guid, kind, subject string, at int64) {
|
||||
t.Helper()
|
||||
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||||
GUID: guid, EventType: kind, Subject: subject, Zone: "holymachina",
|
||||
OccurredAt: at,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelIsSilentOnAFirstVisit. A brand-new row means "never seen before",
|
||||
// and treating that as "away since the epoch" would greet somebody's first
|
||||
// sign-in with every death their character ever suffered.
|
||||
func TestAwayPanelIsSilentOnAFirstVisit(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Hour).Unix())
|
||||
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-1", "josie")); v.Has {
|
||||
t.Errorf("first visit rendered a panel of %d lines; it must be silent", len(v.Lines))
|
||||
}
|
||||
// And the clock was still stamped, so the next visit has a window to read from.
|
||||
from, first, err := storage.AdvVisitWindow("sub-1", time.Now().Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first || from == 0 {
|
||||
t.Errorf("visit clock not stamped on the first pass (from=%d first=%v)", from, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelSurvivesARefresh is the reason adventure_visit has two columns.
|
||||
// The naive one-column version shows the news, moves the stamp to now, and then
|
||||
// renders an empty box over the same events the moment the reader reloads —
|
||||
// which is exactly what somebody does after clicking into a dispatch and back.
|
||||
func TestAwayPanelSurvivesARefresh(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
|
||||
// A visit two hours ago established the clock. Stamped directly rather than
|
||||
// through awayPanel, because the panel reads the wall clock and this test is
|
||||
// about what happens between two visits rather than inside one.
|
||||
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-2*time.Hour).Unix()); err != nil || !first {
|
||||
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||
}
|
||||
// Then something happened to Josie.
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
first := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if !first.Has || len(first.Lines) != 1 {
|
||||
t.Fatalf("panel = %+v, want one line about the death", first)
|
||||
}
|
||||
if first.Name != "Josie" {
|
||||
t.Errorf("panel names %q, want Josie", first.Name)
|
||||
}
|
||||
|
||||
// The refresh. Same panel, not an empty one.
|
||||
again := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if !again.Has || len(again.Lines) != len(first.Lines) {
|
||||
t.Errorf("refresh emptied the panel: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelNeverShowsAnotherPlayersNews. The panel is keyed on a fact's
|
||||
// character name, resolved through the owner join — the same join the alert
|
||||
// sender uses, and the same failure-closed rule. A signed-in visitor who owns
|
||||
// nothing must see nothing, never the realm's news relabelled as their own.
|
||||
func TestAwayPanelNeverShowsAnotherPlayersNews(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix())
|
||||
|
||||
// Anonymous: no panel, and no visit row to create either.
|
||||
if v := s.awayPanel(awayReq(t, s, "", "")); v.Has {
|
||||
t.Error("an anonymous visitor got a personal panel")
|
||||
}
|
||||
// Signed in, but owns no adventurer on the board.
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||
t.Errorf("a visitor with no adventurer got %+v", v)
|
||||
}
|
||||
// Second pass, now that their visit row exists — the branch that would fall
|
||||
// through to a broadcast if the ownership join were ever treated as optional.
|
||||
if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has {
|
||||
t.Errorf("a visitor with no adventurer got %+v on their second visit", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAwayPanelCapsAndCounts: six lines is a glance, and the overflow has to be
|
||||
// counted rather than silently dropped.
|
||||
func TestAwayPanelCapsAndCounts(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
seedAwayOwner(t, "josie", "Josie")
|
||||
|
||||
if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-4*time.Hour).Unix()); err != nil || !first {
|
||||
t.Fatalf("seed visit: first=%v err=%v", first, err)
|
||||
}
|
||||
base := time.Now().Add(-time.Hour).Unix()
|
||||
for i := 0; i < awayCap+3; i++ {
|
||||
seedAwayEvent(t, "boss_kill:"+string(rune('a'+i))+":1", "boss_kill", "Josie", base+int64(i))
|
||||
}
|
||||
|
||||
v := s.awayPanel(awayReq(t, s, "sub-1", "josie"))
|
||||
if len(v.Lines) != awayCap {
|
||||
t.Errorf("panel drew %d lines, want the cap of %d", len(v.Lines), awayCap)
|
||||
}
|
||||
if !v.HasMore {
|
||||
t.Error("overflow was not flagged; the extra events would read as if they never happened")
|
||||
}
|
||||
if v.Token != "tok-josie" {
|
||||
t.Errorf("panel links to %q, want the reader's own adventurer page", v.Token)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,11 @@ type channelPage struct {
|
||||
// even with nothing camped, because the link to the history is the other half
|
||||
// of what makes a live Siege feel like it counts.
|
||||
Siege SiegeView
|
||||
|
||||
// Away is the signed-in owner's "while you were away" panel: what happened to
|
||||
// their own adventurer since their last visit. Zero for everyone else, and
|
||||
// zero for an owner who has missed nothing — see awayPanel.
|
||||
Away awayView
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
@@ -404,6 +409,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
||||
data.Roster, data.RosterStale, _ = s.roster()
|
||||
data.ShowRoster = true
|
||||
data.Siege = s.siege()
|
||||
data.Away = s.awayPanel(r)
|
||||
}
|
||||
s.render(w, "channel", data)
|
||||
}
|
||||
|
||||
@@ -2998,3 +2998,83 @@ html[data-room] .pete-felt {
|
||||
.realm-zone:hover { transform: none; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* W7 small surfaces: the pet XP bar and the party roster, both on the
|
||||
adventurer page. Same purge discipline as the block above — every class here
|
||||
is declared on a plain selector so Tailwind's extractor can lift its literal
|
||||
name out of this file. Nothing here is keyed to a pseudo-element. */
|
||||
|
||||
/* Pet levelling. It has been happening since the XP wiring was fixed and no
|
||||
surface has ever shown it. Deliberately a thin rail rather than a health-bar
|
||||
lookalike: a pet's progress is a nice thing to notice, not a stat to watch. */
|
||||
.pet-xp-track {
|
||||
height: 0.3rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--ink) 10%, var(--card));
|
||||
overflow: hidden;
|
||||
}
|
||||
.pet-xp-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, #3fa66a 70%, var(--ink));
|
||||
}
|
||||
/* At the cap there is nothing left to fill, and an empty track would read as
|
||||
the opposite. Fill it whole and let the label say why. */
|
||||
.pet-xp-capped {
|
||||
background: color-mix(in srgb, #c9a227 72%, var(--ink));
|
||||
}
|
||||
|
||||
/* The party roster. One row per seat, and the three kinds have to be tellable
|
||||
apart at a glance because they mean different things about the run: the
|
||||
leader owns the clock, a member is another player, the companion is hired. */
|
||||
.party-seat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
|
||||
}
|
||||
.party-seat:last-child { border-bottom: 0; }
|
||||
.party-seat-role {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 9999px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--ink) 8%, var(--card));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.party-seat-leader {
|
||||
color: color-mix(in srgb, #6d4bd8 70%, var(--ink));
|
||||
background: color-mix(in srgb, #6d4bd8 12%, var(--card));
|
||||
}
|
||||
/* An opted-out player's seat. It stays on the roster because the party size is
|
||||
load-bearing — the supply burn and the threat level printed on this same page
|
||||
felt that body — but it carries no name and no link. Italic and recessed so
|
||||
it reads as withheld rather than as missing data. */
|
||||
.party-seat-anon {
|
||||
font-style: italic;
|
||||
color: color-mix(in srgb, var(--ink) 45%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* "While you were away" (adventure section, signed-in owner only). Rows are
|
||||
quiet by default; the two kinds of line worth an eye — a realm first, a death
|
||||
— carry a marker. Same plain-selector purge discipline as everything above. */
|
||||
.away-line {
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 0.75rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.away-line:hover {
|
||||
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
}
|
||||
.away-line-notable {
|
||||
border-left-color: color-mix(in srgb, #c9a227 60%, transparent);
|
||||
background: color-mix(in srgb, #c9a227 6%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,6 +14,38 @@
|
||||
</section>
|
||||
|
||||
{{if .ShowRoster}}
|
||||
{{/* While you were away. First thing on the page when it renders at all, and it
|
||||
renders for exactly one reader: the signed-in owner of an adventurer something
|
||||
has happened to since their last visit. Above the Siege because everything
|
||||
below this line is the realm's news and this is the reader's own. */}}
|
||||
{{if .Away.Has}}
|
||||
<section class="mb-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 sm:p-6 shadow-pete">
|
||||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<h2 class="font-display text-xl font-bold">While you were away</h2>
|
||||
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45">{{.Away.Name}} · past {{.Away.Since}}</span>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-2">
|
||||
{{range .Away.Lines}}
|
||||
<li class="away-line{{if .Notable}} away-line-notable{{end}}">
|
||||
<a href="{{.Permalink}}" class="flex items-baseline gap-2.5 group">
|
||||
<span class="shrink-0" aria-hidden="true">{{.Emoji}}</span>
|
||||
<span class="flex-1 text-sm">
|
||||
<span class="font-semibold group-hover:text-theme-adventure group-hover:underline">{{.Label}}</span>
|
||||
{{if .Line}}<span class="text-[color:var(--ink)]/60"> — {{.Line}}</span>{{end}}
|
||||
</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/40 shrink-0 tabular-nums">{{.When}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{if .Away.Token}}
|
||||
<a href="/adventure/who/{{.Away.Token}}" class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-theme-adventure hover:opacity-80 transition">
|
||||
{{if .Away.HasMore}}More, and the rest of the trail{{else}}Your adventurer{{end}} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* The Siege strip. Above the board on purpose: the board is where everyone is,
|
||||
the Siege is where everyone should be. When one is camped this is a live bar
|
||||
and a door into the war room; when none is, it stays as the quiet doorway to
|
||||
|
||||
@@ -115,6 +115,33 @@
|
||||
{{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}}
|
||||
|
||||
{{/* The party. Absent on a solo run, so this block answers "is anybody else
|
||||
down there" rather than drawing a chair count. Server-rendered only: a
|
||||
roster is settled before a party leaves town, so unlike the HP and
|
||||
supply numbers above it there is nothing here for the poll to move.
|
||||
An unnamed seat is an opted-out player, kept on purpose — see
|
||||
partySeat.Anonymous for why it is not simply dropped. */}}
|
||||
{{if .Detail.Party}}
|
||||
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10">
|
||||
<h3 class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50 mb-1">Down there together</h3>
|
||||
<ul>
|
||||
{{range .Detail.Party}}
|
||||
<li class="party-seat">
|
||||
<span class="party-seat-role{{if eq .Kind "leader"}} party-seat-leader{{end}}">{{.Kind}}</span>
|
||||
{{if .Anonymous}}
|
||||
<span class="party-seat-anon flex-1 text-sm">an adventurer who keeps out of the news</span>
|
||||
{{else if .Token}}
|
||||
<a href="/adventure/who/{{.Token}}" class="flex-1 text-sm font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a>
|
||||
{{else}}
|
||||
<span class="flex-1 text-sm font-semibold">{{.Name}}</span>
|
||||
{{end}}
|
||||
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
||||
@@ -519,13 +546,27 @@
|
||||
{{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>
|
||||
|
||||
{{/* Pets, with their levelling shown. They have been earning XP from every
|
||||
won fight since that wiring was fixed, and until now the only place a
|
||||
level appeared was a Matrix line that scrolled away. Ranges over
|
||||
.PetRows, not .Self.Pets: the progress figures are worked out in Go
|
||||
from the centi-XP gogobee sends, because the curve behind the target
|
||||
is the engine's and there is no copy of it here. */}}
|
||||
<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>
|
||||
{{if .PetRows}}
|
||||
<ul class="space-y-3 text-sm">
|
||||
{{range .PetRows}}
|
||||
<li>
|
||||
<div 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>
|
||||
</div>
|
||||
<div class="pet-xp-track mt-1.5">
|
||||
<div class="pet-xp-fill{{if .Capped}} pet-xp-capped{{end}}" style="width: {{if .Capped}}100{{else}}{{.Percent}}{{end}}%"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[color:var(--ink)]/45">
|
||||
{{if .Capped}}fully grown{{else}}{{.Progress}} to level {{.NextLevel}}{{end}}
|
||||
</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
@@ -34,6 +36,78 @@ type whoDetail struct {
|
||||
ThreatLevel int `json:"threat_level"`
|
||||
Room string `json:"room"`
|
||||
Map *whoMap `json:"map"`
|
||||
// Party is who else is down there, leader first. Absent on a solo run.
|
||||
Party []partySeat `json:"party"`
|
||||
}
|
||||
|
||||
// partySeat is one body on a shared expedition as gogobee described it. Kind is
|
||||
// "leader", "member" or "companion" — the game keeps those three carefully
|
||||
// distinct and so does this page: a companion fights but is nobody's account, so
|
||||
// he is named without a link and never counted as a player.
|
||||
//
|
||||
// A seat with a Kind but no Name is an opted-out player, kept on purpose. gogobee
|
||||
// anonymises rather than deletes here (the Siege contributor rule, not the realm
|
||||
// occupant rule), because a party of three rendered as a pair contradicts the
|
||||
// supply burn and threat level printed beside it. Render it as an unnamed seat;
|
||||
// never as an absent one, and never with a link.
|
||||
type partySeat struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// Anonymous reports whether this seat belongs to a player who has opted out of
|
||||
// the news. Keyed on the name rather than the token because the two come apart
|
||||
// only in the direction that matters: gogobee never sends a token without a name.
|
||||
func (p partySeat) Anonymous() bool { return p.Kind != "companion" && p.Name == "" }
|
||||
|
||||
// petRow is one pet with its levelling made legible. gogobee sends centi-XP and
|
||||
// the engine's own threshold for the pet's current level band; the arithmetic
|
||||
// here is presentation only — a percentage for the bar and a decimal for the
|
||||
// label — and there is deliberately no copy of the curve on this side.
|
||||
type petRow struct {
|
||||
storage.PetView
|
||||
// Capped is the level ceiling: gogobee reports 0 needed, which is not the same
|
||||
// as an empty bar and must not render as one.
|
||||
Capped bool
|
||||
// Percent is 0-100 for the bar's width. Clamped, because a pet can sit above
|
||||
// its own threshold for the moment between earning XP and the next level-up
|
||||
// pass, and a bar wider than its track breaks the layout rather than the maths.
|
||||
Percent int
|
||||
// Progress is the human label: "7.5 / 20".
|
||||
Progress string
|
||||
// NextLevel is what the bar is filling toward. 0 when capped.
|
||||
NextLevel int
|
||||
}
|
||||
|
||||
// petRows makes the pushed pets renderable. Pets are owner-only — the public
|
||||
// sheet has never carried them — so this runs behind the ownership join.
|
||||
func petRows(pets []storage.PetView) []petRow {
|
||||
if len(pets) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]petRow, 0, len(pets))
|
||||
for _, p := range pets {
|
||||
row := petRow{PetView: p, Capped: p.XPNeeded <= 0}
|
||||
if !row.Capped {
|
||||
row.Percent = min(100, max(0, p.XP*100/p.XPNeeded))
|
||||
row.Progress = fmt.Sprintf("%s / %s", centiXP(p.XP), centiXP(p.XPNeeded))
|
||||
row.NextLevel = p.Level + 1
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// centiXP renders the game's hundredths as the number a player recognises. A pet
|
||||
// earns 1.5 XP per action, so the halves are real and dropping them would make a
|
||||
// bar that visibly moved report the same figure twice.
|
||||
func centiXP(centi int) string {
|
||||
if centi%100 == 0 {
|
||||
return strconv.Itoa(centi / 100)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(centi)/100), "0"), ".")
|
||||
}
|
||||
|
||||
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||
@@ -95,6 +169,7 @@ type whoPage struct {
|
||||
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
|
||||
@@ -244,6 +319,7 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
|
||||
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++
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The two W7 additions to the adventurer page: the party roster (public, rides
|
||||
// the roster detail) and pet levelling (owner-only, rides the self detail). Both
|
||||
// are driven through the real templates, so a field slip 500s here.
|
||||
|
||||
// seedPartyWho puts Josie on the board mid-run with a three-seat party: her, a
|
||||
// named companion player, an opted-out player (anonymised upstream: kind but no
|
||||
// name), and the hireling.
|
||||
func seedPartyWho(t *testing.T) *Server {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"hp_current": 30,
|
||||
"hp_max": 42,
|
||||
"armor_class": 17,
|
||||
"abilities": [6]int{16, 14, 15, 10, 12, 8},
|
||||
"modifiers": [6]int{3, 2, 2, 0, 1, -1},
|
||||
"supplies": 8,
|
||||
"room": "3 / 7",
|
||||
"party": []map[string]any{
|
||||
{"kind": "leader", "name": "Josie", "token": "tok-josie", "level": 14},
|
||||
{"kind": "member", "name": "Camcast", "token": "tok-cam", "level": 12},
|
||||
{"kind": "member"}, // opted out: anonymised upstream
|
||||
{"kind": "companion", "name": "Pete"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := entry("tok-josie", "Josie", "expedition", "holymachina")
|
||||
e.Level = 14
|
||||
e.ClassRace = "human cleric"
|
||||
e.Detail = raw
|
||||
if w := postRoster(t, s, "tok", rosterPush{
|
||||
SnapshotAt: time.Now().Unix(), Adventurers: []storage.RosterEntry{e},
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestWhoDrawsThePartyWithoutNamingAnOptOut. The roster has to add up — a party
|
||||
// of four rendered as three contradicts the supply burn printed beside it — while
|
||||
// the seat belonging to a player who keeps out of the news carries no name and no
|
||||
// link out.
|
||||
func TestWhoDrawsThePartyWithoutNamingAnOptOut(t *testing.T) {
|
||||
s := seedPartyWho(t)
|
||||
|
||||
w := getWho(t, s, "tok-josie", "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET who = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Down there together", "Camcast", "Pete", "leader", "companion"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("party block missing %q", want)
|
||||
}
|
||||
}
|
||||
// Four seats drawn, not three.
|
||||
if got := strings.Count(body, `class="party-seat"`); got != 4 {
|
||||
t.Errorf("drew %d seats, want 4 — an anonymised seat must still occupy a chair", got)
|
||||
}
|
||||
if !strings.Contains(body, "keeps out of the news") {
|
||||
t.Error("the anonymised seat rendered as nothing at all")
|
||||
}
|
||||
// The named member links to their own page; the hireling and the anonymous
|
||||
// seat have nothing to link to.
|
||||
if !strings.Contains(body, `/adventure/who/tok-cam`) {
|
||||
t.Error("a named party member is not linked to their page")
|
||||
}
|
||||
// Exactly two seat links: the two seats carrying tokens. The hireling and the
|
||||
// anonymised seat must be text, not doors. (Counted on the href so the page's
|
||||
// own /api/adventure/who/ poll URL does not register as a link.)
|
||||
if got := strings.Count(body, `href="/adventure/who/`); got != 2 {
|
||||
t.Errorf("seat links = %d, want 2 — only a named seat may link out", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSoloRunDrawsNoPartyBlock: the block is the answer to "is anybody else down
|
||||
// there", so on a solo run it must not appear at all rather than list one chair.
|
||||
func TestSoloRunDrawsNoPartyBlock(t *testing.T) {
|
||||
s := seedWho(t, "josie") // no party in its detail blob
|
||||
|
||||
w := getWho(t, s, "tok-josie", "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET who = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "Down there together") {
|
||||
t.Error("a solo run drew a party block")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPetLevellingIsVisibleToTheOwner. Pets have been earning XP from every won
|
||||
// fight since that wiring was fixed and no web surface has ever shown it. The
|
||||
// unit matters more than the bar: XP arrives as centi-XP, so a page that printed
|
||||
// it raw would tell somebody their cat has 750 of 20 points.
|
||||
func TestPetLevellingIsVisibleToTheOwner(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", "idle", "")
|
||||
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: "josie", Token: "tok-josie",
|
||||
Pets: []storage.PetView{
|
||||
{Type: "cat", Name: "Mittens", Level: 4, XP: 750, XPNeeded: 2000},
|
||||
{Type: "dog", Name: "Rex", Level: 10}, // capped: nothing left to earn
|
||||
},
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
|
||||
body := getWho(t, s, "tok-josie", "josie").Body.String()
|
||||
if !strings.Contains(body, "7.5 / 20 to level 5") {
|
||||
t.Error("Mittens' progress is not rendered in whole XP — check the centi-XP divide")
|
||||
}
|
||||
if strings.Contains(body, "750") {
|
||||
t.Error("raw centi-XP leaked onto the page")
|
||||
}
|
||||
if !strings.Contains(body, "fully grown") {
|
||||
t.Error("a capped pet has no label saying why its bar is full")
|
||||
}
|
||||
if !strings.Contains(body, "pet-xp-capped") {
|
||||
t.Error("a capped pet's bar is styled like an in-progress one")
|
||||
}
|
||||
// A capped pet must not render an empty track, which reads as the opposite of
|
||||
// what it means.
|
||||
if strings.Contains(body, `class="pet-xp-fill" style="width: 0%"`) {
|
||||
t.Error("a capped pet drew an empty bar")
|
||||
}
|
||||
|
||||
// And none of it leaks to a visitor: pets are owner-only.
|
||||
if strings.Contains(getWho(t, s, "tok-josie", "").Body.String(), "Mittens") {
|
||||
t.Error("a visitor can see the owner's pets")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user