Files
prosolis c2a40dad64 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.
2026-07-24 20:26:19 -07:00

165 lines
5.6 KiB
Go

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)
}
}