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:
@@ -166,11 +166,18 @@ type HouseView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PetView is one pet slot.
|
// PetView is one pet slot.
|
||||||
|
//
|
||||||
|
// XP and XPNeeded are both **centi-XP**, the game's own unit: a pet earns 1.5
|
||||||
|
// points per action and the stored ledger is an integer, so everything is kept
|
||||||
|
// times a hundred. Divide by 100 to show a number to a human; do nothing else
|
||||||
|
// with either. XPNeeded is the engine's per-band curve and is 0 at the level cap,
|
||||||
|
// which is the only way to tell "full" from "nothing left to earn".
|
||||||
type PetView struct {
|
type PetView struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
XP int `json:"xp,omitempty"`
|
XP int `json:"xp,omitempty"`
|
||||||
|
XPNeeded int `json:"xp_needed,omitempty"`
|
||||||
ArmorTier int `json:"armor_tier,omitempty"`
|
ArmorTier int `json:"armor_tier,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,6 +449,22 @@ CREATE TABLE IF NOT EXISTS player_self_detail (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
||||||
|
|
||||||
|
-- Per-user visit clock for the adventure section's "while you were away" panel,
|
||||||
|
-- keyed by OIDC subject like every other per-user table.
|
||||||
|
--
|
||||||
|
-- TWO stamps, and the second one is the whole trick. window_from is where the
|
||||||
|
-- panel reads from; last_seen_at is a heartbeat written on every page load. One
|
||||||
|
-- column would make the panel a one-shot: it would show what happened, move the
|
||||||
|
-- stamp to now, and a refresh five seconds later would render an empty box over
|
||||||
|
-- the same news. So window_from advances only when a genuinely new visit begins
|
||||||
|
-- (see AdvVisitWindow), which keeps the panel stable for as long as somebody is
|
||||||
|
-- actually reading it.
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_visit (
|
||||||
|
user_sub TEXT PRIMARY KEY,
|
||||||
|
window_from INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_log (
|
CREATE TABLE IF NOT EXISTS post_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The visit clock behind the adventure section's "while you were away" panel.
|
||||||
|
//
|
||||||
|
// The panel answers "what happened to my adventurer since I last looked", which
|
||||||
|
// needs a per-user stamp — and the obvious one-column version of that is broken
|
||||||
|
// in a way that only shows up in a browser: show the news, move the stamp to now,
|
||||||
|
// and the reader's first refresh renders an empty box over the same events. So
|
||||||
|
// there are two stamps. See the adventure_visit schema comment.
|
||||||
|
|
||||||
|
// advVisitSessionGap is how long a gap in page loads counts as having gone away.
|
||||||
|
// Thirty minutes: long enough that a reader clicking through a dispatch and back
|
||||||
|
// keeps the same panel, short enough that "since last time" means something after
|
||||||
|
// a lunch break rather than only after a day.
|
||||||
|
const advVisitSessionGap = 30 * 60
|
||||||
|
|
||||||
|
// AdvVisitWindow stamps this visit and reports the instant the panel should read
|
||||||
|
// from — every dispatch after it is news to this user.
|
||||||
|
//
|
||||||
|
// firstVisit is true the first time a user is ever seen, and the caller must show
|
||||||
|
// nothing for it. The row is created stamped to now, so their history is not
|
||||||
|
// news: somebody signing in for the first time has not been "away", and greeting
|
||||||
|
// them with every death their character ever suffered would be a worse
|
||||||
|
// introduction than silence.
|
||||||
|
func AdvVisitWindow(userSub string, now int64) (from int64, firstVisit bool, err error) {
|
||||||
|
if userSub == "" {
|
||||||
|
return 0, true, nil
|
||||||
|
}
|
||||||
|
var windowFrom, lastSeen int64
|
||||||
|
err = Get().QueryRow(
|
||||||
|
`SELECT window_from, last_seen_at FROM adventure_visit WHERE user_sub = ?`,
|
||||||
|
userSub).Scan(&windowFrom, &lastSeen)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
_, ierr := Get().Exec(
|
||||||
|
`INSERT INTO adventure_visit (user_sub, window_from, last_seen_at) VALUES (?, ?, ?)`,
|
||||||
|
userSub, now, now)
|
||||||
|
return now, true, ierr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new visit starts when the heartbeat has gone quiet for longer than the
|
||||||
|
// session gap. Only then does the window move — and it moves to where the
|
||||||
|
// reader actually left off (lastSeen), never to now, or the events between
|
||||||
|
// their last page load and this one would fall down the crack between the two.
|
||||||
|
if now-lastSeen > advVisitSessionGap {
|
||||||
|
windowFrom = lastSeen
|
||||||
|
}
|
||||||
|
_, err = Get().Exec(
|
||||||
|
`UPDATE adventure_visit SET window_from = ?, last_seen_at = ? WHERE user_sub = ?`,
|
||||||
|
windowFrom, now, userSub)
|
||||||
|
return windowFrom, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsBySubjectSince is EventsBySubject narrowed to what is new. The limit is
|
||||||
|
// applied to the *window*, not to the subject's whole history, so a player back
|
||||||
|
// from a long absence gets the most recent N of what they missed rather than N
|
||||||
|
// rows scanned from a history that might all predate the window.
|
||||||
|
func EventsBySubjectSince(name string, sinceUnix int64, limit int) ([]AdvEvent, error) {
|
||||||
|
if name == "" || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := Get().Query(`
|
||||||
|
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
|
||||||
|
level, tally, outcome, milestone, stakes, run_id, occurred_at
|
||||||
|
FROM adventure_events
|
||||||
|
WHERE (subject = ? OR opponent = ?) AND occurred_at > ?
|
||||||
|
ORDER BY occurred_at DESC
|
||||||
|
LIMIT ?`, name, name, sinceUnix, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AdvEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e AdvEvent
|
||||||
|
var tier, subject, opponent, boss, zone, region sql.NullString
|
||||||
|
var outcome, milestone, stakes, runID sql.NullString
|
||||||
|
if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
|
||||||
|
&boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone,
|
||||||
|
&stakes, &runID, &e.OccurredAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
|
||||||
|
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
|
||||||
|
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
|
||||||
|
e.RunID = runID.String
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAwayWindowOnlyMovesOnANewVisit pins the session gap directly. Within the
|
||||||
|
// gap the window is held; past it, it advances to where the reader actually left
|
||||||
|
// off — never to now, or everything between their last load and this one would
|
||||||
|
// fall down the crack.
|
||||||
|
func TestAwayWindowOnlyMovesOnANewVisit(t *testing.T) {
|
||||||
|
if err := Init(t.TempDir() + "/visit.db"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { Close() })
|
||||||
|
|
||||||
|
t0 := int64(1_000_000)
|
||||||
|
if _, first, err := AdvVisitWindow("sub-1", t0); err != nil || !first {
|
||||||
|
t.Fatalf("first call: first=%v err=%v", first, err)
|
||||||
|
}
|
||||||
|
// A minute later: same visit, window pinned to where it started.
|
||||||
|
from, _, err := AdvVisitWindow("sub-1", t0+60)
|
||||||
|
if err != nil || from != t0 {
|
||||||
|
t.Fatalf("window = %d (err %v), want it held at %d inside the session", from, err, t0)
|
||||||
|
}
|
||||||
|
// Well past the gap: a new visit, reading from the last heartbeat (t0+60),
|
||||||
|
// not from now.
|
||||||
|
from, _, err = AdvVisitWindow("sub-1", t0+60+advVisitSessionGap+1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if from != t0+60 {
|
||||||
|
t.Errorf("window = %d, want the previous heartbeat %d — anything else drops or replays events",
|
||||||
|
from, t0+60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||||||
// did before the report existed.
|
// 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 {
|
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||||||
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||||||
} else {
|
} else {
|
||||||
runReport = runReportLinkFor(ev)
|
runReport = runReportLinkFor(ev)
|
||||||
|
if ev != nil {
|
||||||
|
region = ev.Region
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
base := s.base(r)
|
base := s.base(r)
|
||||||
@@ -431,7 +438,7 @@ func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
|
|||||||
Emoji: emoji,
|
Emoji: emoji,
|
||||||
Headline: st.Headline,
|
Headline: st.Headline,
|
||||||
Body: body,
|
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"),
|
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||||||
Permalink: s.advPermalink(guid),
|
Permalink: s.advPermalink(guid),
|
||||||
RunReportURL: runReport,
|
RunReportURL: runReport,
|
||||||
|
|||||||
@@ -551,3 +551,52 @@ func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
|||||||
t.Error("fact-guard rejection was stored anyway")
|
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
|
// even with nothing camped, because the link to the history is the other half
|
||||||
// of what makes a live Siege feel like it counts.
|
// of what makes a live Siege feel like it counts.
|
||||||
Siege SiegeView
|
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 {
|
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.Roster, data.RosterStale, _ = s.roster()
|
||||||
data.ShowRoster = true
|
data.ShowRoster = true
|
||||||
data.Siege = s.siege()
|
data.Siege = s.siege()
|
||||||
|
data.Away = s.awayPanel(r)
|
||||||
}
|
}
|
||||||
s.render(w, "channel", data)
|
s.render(w, "channel", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2998,3 +2998,83 @@ html[data-room] .pete-felt {
|
|||||||
.realm-zone:hover { transform: none; }
|
.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>
|
</section>
|
||||||
|
|
||||||
{{if .ShowRoster}}
|
{{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 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
|
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
|
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}}
|
{{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>
|
</div>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
|
|
||||||
{{/* Public gear list. Hidden only when the owner's "Equipment" panel below will
|
{{/* 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}}
|
{{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>
|
</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>
|
<h3 class="font-display text-lg font-bold mt-6 mb-3">Pets</h3>
|
||||||
{{if .Self.Pets}}
|
{{if .PetRows}}
|
||||||
<ul class="space-y-2 text-sm">
|
<ul class="space-y-3 text-sm">
|
||||||
{{range .Self.Pets}}
|
{{range .PetRows}}
|
||||||
<li class="flex items-baseline justify-between gap-3">
|
<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="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>
|
<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>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"pete/internal/storage"
|
||||||
@@ -34,6 +36,78 @@ type whoDetail struct {
|
|||||||
ThreatLevel int `json:"threat_level"`
|
ThreatLevel int `json:"threat_level"`
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
Map *whoMap `json:"map"`
|
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
|
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
|
||||||
@@ -95,6 +169,7 @@ type whoPage struct {
|
|||||||
Worn []itemRow
|
Worn []itemRow
|
||||||
Backpack []itemRow
|
Backpack []itemRow
|
||||||
VaultRows []itemRow
|
VaultRows []itemRow
|
||||||
|
PetRows []petRow
|
||||||
BondsUsed int
|
BondsUsed int
|
||||||
// History. Unlike everything above, these are not a gogobee snapshot — they
|
// History. Unlike everything above, these are not a gogobee snapshot — they
|
||||||
// are counted from the facts Pete has been keeping since adventure_events
|
// 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.Worn = itemRows(self.Equipped, "worn")
|
||||||
page.Backpack = itemRows(self.Inventory, "backpack")
|
page.Backpack = itemRows(self.Inventory, "backpack")
|
||||||
page.VaultRows = itemRows(self.Vault, "vault")
|
page.VaultRows = itemRows(self.Vault, "vault")
|
||||||
|
page.PetRows = petRows(self.Pets)
|
||||||
for _, it := range self.Equipped {
|
for _, it := range self.Equipped {
|
||||||
if it.Attuned {
|
if it.Attuned {
|
||||||
page.BondsUsed++
|
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