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:
prosolis
2026-07-24 20:26:19 -07:00
parent 868a29e992
commit c2a40dad64
15 changed files with 935 additions and 9 deletions
+164
View File
@@ -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)
}
}