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

98 lines
3.9 KiB
Go

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, &region, &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()
}