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:
@@ -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