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.
|
||||
//
|
||||
// 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 string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Level int `json:"level"`
|
||||
XP int `json:"xp,omitempty"`
|
||||
XPNeeded int `json:"xp_needed,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);
|
||||
|
||||
-- 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user