adventure: tell a player what happened while they were away

The site could only reach somebody who was already looking at it. Push
existed and adventure used none of it, so the one communal event in the
game -- the Siege -- was invisible to anyone not sitting in Matrix, and a
player whose adventurer died found out whenever they next opened a tab.

Four opt-in categories, every one of them off until asked for: the Siege
(realm-wide, begins and ends), your expedition ending, your adventurer
wandering off, and a contract landing on you. Turning on news
notifications is not consent to be told about the game, so nothing here
enrolls anybody automatically.

No new wire. Every trigger is a dispatch already landing in
adventure_events, so this is Pete-side only and gogobee is untouched.

Two things it needed from storage. push_subscriptions now keeps the
Matrix localpart alongside the OIDC subject, because every ownership
join in the schema is keyed on the localpart and the sender runs on a
ticker with no session to read one from -- without it there is no way to
answer "whose adventurer is this". And the alerts carry their own
watermark, kept apart from the digest's: the two run on different clocks
and one column would let each consume the other's backlog.

The ownership join is re-read on every pass rather than trusted from the
subscription row, so an opt-out or a removal closes the channel at once.
It fails closed in both directions, and an unresolved owner can never
fall through to a broadcast -- a game alert naming somebody's adventurer,
delivered to the wrong phone, is a privacy leak dressed as a feature.

An existing subscription carries watermark 0, which read literally means
"has never been told anything" and would page every subscriber for the
whole history of the realm on the first tick after deploy. Those rows are
stamped to now and start from the next dispatch.

Verified against a running Pete with a real push service, real P-256
client keys and real encryption: the right person is notified, the wrong
one is not, a second pass is silent, and dropping the player from the
board takes the channel with it.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 18:22:34 -07:00
parent 1dfd3ac9fb
commit b19ab5eff0
15 changed files with 1135 additions and 62 deletions
+12
View File
@@ -117,6 +117,18 @@ func runMigrations(d *sql.DB) error {
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
// Adventure alerts. A subscription made before they existed knows only the OIDC
// subject, and the adventure ownership join needs the Matrix localpart — so an
// existing row gets "" here and is skipped for owner-scoped alerts until the
// browser re-subscribes, which it does on every page load that has push on.
// Realm-wide alerts (the Siege) need no localpart and work immediately.
addColumnIfMissing(d, "push_subscriptions", "user_localpart", "TEXT NOT NULL DEFAULT ''")
// The adventure watermark is deliberately separate from last_notified_at: the
// digest and the alerts run on different clocks (6 hours vs 2 minutes), and
// sharing one column would let whichever ran last decide what the other had
// already seen. 0 on a pre-existing row is corrected to "now" on the first
// pass rather than replaying every dispatch Pete has ever stored.
addColumnIfMissing(d, "push_subscriptions", "last_adv_notified_at", "INTEGER NOT NULL DEFAULT 0")
// FTS5 virtual tables don't support IF NOT EXISTS reliably.
// Check sqlite_master before creating.
+41 -15
View File
@@ -5,29 +5,40 @@ import "fmt"
// PushSubscription is one browser/device endpoint a signed-in user has opted in
// for Web Push digests. See the push_subscriptions schema for the field roles.
type PushSubscription struct {
Endpoint string
UserSub string
P256dh string
Auth string
CreatedAt int64
LastNotifiedAt int64
Endpoint string
UserSub string
Localpart string
P256dh string
Auth string
CreatedAt int64
LastNotifiedAt int64
LastAdvNotifiedAt int64
}
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
// endpoint is the primary key, so a re-subscribe from the same browser updates
// the keys and resets the digest watermark to now — the user shouldn't be
// paged for everything published before they opted in.
func AddPushSubscription(sub, endpoint, p256dh, auth string) error {
// the keys and resets both watermarks to now — the user shouldn't be paged for
// everything published before they opted in.
//
// localpart is the session's Matrix handle, refreshed on every re-subscribe so a
// row stored by a build that predated adventure alerts heals itself the first
// time that browser subscribes again. It may legitimately be empty (a session
// minted before the game economy existed carries no username); such a row simply
// never matches an owner-scoped alert.
func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
now := nowUnix()
_, err := Get().Exec(`
INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO push_subscriptions
(endpoint, user_sub, user_localpart, p256dh, auth, created_at, last_notified_at, last_adv_notified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_sub = excluded.user_sub,
user_localpart = excluded.user_localpart,
p256dh = excluded.p256dh,
auth = excluded.auth,
last_notified_at = excluded.last_notified_at`,
endpoint, sub, p256dh, auth, now, now)
last_notified_at = excluded.last_notified_at,
last_adv_notified_at = excluded.last_adv_notified_at`,
endpoint, sub, localpart, p256dh, auth, now, now, now)
if err != nil {
return fmt.Errorf("add push subscription: %w", err)
}
@@ -61,7 +72,8 @@ func RemovePushSubscriptionForUser(sub, endpoint string) error {
// ListPushSubscriptions returns every stored subscription, for the digest sender.
func ListPushSubscriptions() ([]PushSubscription, error) {
rows, err := Get().Query(
`SELECT endpoint, user_sub, p256dh, auth, created_at, last_notified_at
`SELECT endpoint, user_sub, user_localpart, p256dh, auth,
created_at, last_notified_at, last_adv_notified_at
FROM push_subscriptions`)
if err != nil {
return nil, err
@@ -70,7 +82,8 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
var out []PushSubscription
for rows.Next() {
var p PushSubscription
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil {
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.Localpart, &p.P256dh, &p.Auth,
&p.CreatedAt, &p.LastNotifiedAt, &p.LastAdvNotifiedAt); err != nil {
return nil, err
}
out = append(out, p)
@@ -78,6 +91,19 @@ func ListPushSubscriptions() ([]PushSubscription, error) {
return out, rows.Err()
}
// TouchAdvPushSubscription advances an endpoint's *adventure alert* watermark, so
// the next pass only considers dispatches that occurred after ts. Kept separate
// from TouchPushSubscription for the reason the schema gives: the digest and the
// alerts must not be able to consume each other's backlog.
func TouchAdvPushSubscription(endpoint string, ts int64) error {
_, err := Get().Exec(
`UPDATE push_subscriptions SET last_adv_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
if err != nil {
return fmt.Errorf("touch adventure push watermark: %w", err)
}
return nil
}
// TouchPushSubscription advances an endpoint's digest watermark so its next
// digest only considers stories seen after ts.
func TouchPushSubscription(endpoint string, ts int64) error {
+86
View File
@@ -0,0 +1,86 @@
package storage
import (
"database/sql"
)
// The reads behind adventure push alerts. The alert sender needs two things the
// rest of the storage layer does not: dispatches ordered by when they *happened*
// rather than by subject, and a way to turn a signed-in identity into the
// character name a fact would carry.
// AdvEventsSince returns dispatches that occurred after sinceUnix, newest first,
// capped at limit.
//
// The clock is occurred_at, not an arrival time, and that choice has a
// consequence worth stating: a dispatch that reaches Pete late but describes
// something old — a queue row unparked months after the fact, which W0's
// inversion made possible — sorts behind the watermark and is never alerted on.
// That is the outcome we want. An alert is a claim that something is happening
// now, and a phone buzzing about a hire from March would be a lie told urgently.
func AdvEventsSince(sinceUnix int64, limit int) ([]AdvEvent, error) {
if 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 occurred_at > ?
ORDER BY occurred_at DESC
LIMIT ?`, 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()
}
// AdvCharacterForOwner returns the character name currently on the board for a
// signed-in user's localpart, which is the join an owner-scoped alert needs: a
// fact carries a character *name*, a session carries a localpart, and the only
// thing that connects them is the owner-private (localpart -> token) row gogobee
// pushes alongside the public (token -> name) board.
//
// It fails closed, and every way it can fail is a way it should:
//
// - no self-detail row: gogobee has stopped pushing for this player, so Pete
// has no current basis to claim any name is theirs.
// - no roster row for the token: the player is off the board — removed, or
// opted out of the news entirely. An opted-out player's facts are anonymised
// on the wire anyway, so there is no name left to match even in principle.
//
// Both cases mean the caller sends nothing, which is the correct answer to "is
// this dispatch about you" when Pete cannot honestly tell.
func AdvCharacterForOwner(localpart string) (string, bool) {
if localpart == "" {
return "", false
}
var name string
err := Get().QueryRow(`
SELECT r.name
FROM player_self_detail d
JOIN adventure_roster r ON r.token = d.token
WHERE d.localpart = ?`, localpart).Scan(&name)
if err != nil || name == "" {
return "", false
}
return name, true
}
+155
View File
@@ -0,0 +1,155 @@
package storage
import "testing"
// seedOwnedCharacter puts a player on the board and gives them an owner, which
// is the two-row arrangement AdvCharacterForOwner has to walk: the public
// (token -> name) board plus the owner-private (localpart -> token) detail row.
func seedOwnedCharacter(t *testing.T, localpart, token, name string) {
t.Helper()
if err := ReplaceRoster([]RosterEntry{{
Token: token, Name: name, Level: 14, ClassRace: "Cleric", Status: "idle",
}}, 1000); err != nil {
t.Fatal(err)
}
if err := ReplacePlayerDetail([]PlayerDetail{{Localpart: localpart, Token: token}}, 1000); err != nil {
t.Fatal(err)
}
}
// TestAdvCharacterForOwnerNeedsBothHalves is the privacy contract behind every
// owner-scoped alert. The sender asks "which character is this subscriber's" and
// then compares that name against a dispatch's subject; if this function ever
// answered generously, somebody's phone would buzz about another player's death.
//
// Each half of the join is removed in turn, because each one goes missing for a
// real reason in production: the roster row disappears when a player opts out of
// the news or leaves the board, and the self-detail row disappears when gogobee
// stops pushing for them. Both must fail closed.
func TestAdvCharacterForOwnerNeedsBothHalves(t *testing.T) {
setupTestDB(t)
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
if name, ok := AdvCharacterForOwner("josie"); !ok || name != "Josie" {
t.Fatalf("owner lookup = %q/%v, want Josie/true", name, ok)
}
if _, ok := AdvCharacterForOwner("quack"); ok {
t.Error("a localpart with no self-detail row resolved to a character")
}
if _, ok := AdvCharacterForOwner(""); ok {
t.Error("an empty localpart resolved to a character")
}
// Off the board — opted out of the news, or removed. The self-detail row is
// still there and still points at tok-josie, so only the roster join stops
// this. An opted-out player's facts are anonymised on the wire anyway, so
// there would be no name left to match even if this leaked.
if err := ReplaceRoster(nil, 2000); err != nil {
t.Fatal(err)
}
if name, ok := AdvCharacterForOwner("josie"); ok {
t.Errorf("off-the-board player still resolved to %q; alerts must close with the board", name)
}
// And the mirror case: on the board, but gogobee has stopped pushing the
// owner-private half, so Pete has no basis to claim the name is theirs.
seedOwnedCharacter(t, "josie", "tok-josie", "Josie")
if err := ReplacePlayerDetail(nil, 3000); err != nil {
t.Fatal(err)
}
if name, ok := AdvCharacterForOwner("josie"); ok {
t.Errorf("resolved %q with no self-detail row; the ownership claim has no source", name)
}
}
// TestAdvEventsSinceIsOrderedNewestFirst pins the ordering the sender depends on
// twice over: it takes the first match as "the newest thing to tell you about",
// and it advances every watermark to events[0]. Reverse this and the alert names
// the oldest unseen dispatch while the watermark skips the rest.
func TestAdvEventsSinceIsOrderedNewestFirst(t *testing.T) {
setupTestDB(t)
for _, e := range []AdvEvent{
{GUID: "g1", EventType: "death", Subject: "Josie", OccurredAt: 100},
{GUID: "g2", EventType: "zone_clear", Subject: "Josie", OccurredAt: 300},
{GUID: "g3", EventType: "retreat", Subject: "Josie", OccurredAt: 200},
} {
if err := InsertAdventureEvent(&e); err != nil {
t.Fatal(err)
}
}
got, err := AdvEventsSince(150, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d events after ts=150, want 2 (the ts=100 one is behind the watermark)", len(got))
}
if got[0].GUID != "g2" || got[1].GUID != "g3" {
t.Fatalf("order = %s,%s; want g2,g3 (newest first)", got[0].GUID, got[1].GUID)
}
// limit <= 0 means "nothing", not "everything": the sender treats the result
// as a bounded window and an unbounded read here would be a surprise.
if got, _ := AdvEventsSince(0, 0); len(got) != 0 {
t.Errorf("limit 0 returned %d events, want 0", len(got))
}
}
// TestAdvEventsSinceCarriesRunID pins that the run link survives the read. The
// alert for an ended expedition points at the run report when there is one, and
// that field is the only thing distinguishing it from the plain story permalink.
func TestAdvEventsSinceCarriesRunID(t *testing.T) {
setupTestDB(t)
if err := InsertAdventureEvent(&AdvEvent{
GUID: "g1", EventType: "zone_clear", Subject: "Josie",
RunID: "run-7", OccurredAt: 500,
}); err != nil {
t.Fatal(err)
}
got, err := AdvEventsSince(0, 10)
if err != nil || len(got) != 1 {
t.Fatalf("read back %d events (err %v), want 1", len(got), err)
}
if got[0].RunID != "run-7" {
t.Errorf("run id = %q, want run-7", got[0].RunID)
}
}
// TestAdvWatermarkIsIndependentOfTheDigest pins the schema note. The two senders
// run on different clocks; if they shared a column, whichever ran last would
// decide what the other had already seen, and one of the two channels would go
// permanently quiet in a way nobody would think to look for.
func TestAdvWatermarkIsIndependentOfTheDigest(t *testing.T) {
setupTestDB(t)
const ep = "https://push.example/ep"
if err := AddPushSubscription("sub-1", "josie", ep, "p", "a"); err != nil {
t.Fatal(err)
}
if err := TouchAdvPushSubscription(ep, 4242); err != nil {
t.Fatal(err)
}
subs, _ := ListPushSubscriptions()
if len(subs) != 1 {
t.Fatalf("got %d subscriptions, want 1", len(subs))
}
if subs[0].LastAdvNotifiedAt != 4242 {
t.Errorf("adventure watermark = %d, want 4242", subs[0].LastAdvNotifiedAt)
}
if subs[0].LastNotifiedAt == 4242 {
t.Error("touching the adventure watermark moved the digest watermark too")
}
if subs[0].Localpart != "josie" {
t.Errorf("localpart = %q, want josie; owner-scoped alerts have nothing to join on without it", subs[0].Localpart)
}
// The reverse direction, so neither can quietly consume the other's backlog.
if err := TouchPushSubscription(ep, 99); err != nil {
t.Fatal(err)
}
subs, _ = ListPushSubscriptions()
if subs[0].LastAdvNotifiedAt != 4242 {
t.Errorf("digest touch moved the adventure watermark to %d", subs[0].LastAdvNotifiedAt)
}
}
+5 -5
View File
@@ -5,15 +5,15 @@ import "testing"
func TestPushSubscriptionLifecycle(t *testing.T) {
setupTestDB(t)
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a", "auth-a"); err != nil {
t.Fatal(err)
}
// A second endpoint for the same user (e.g. a second device).
if err := AddPushSubscription("sub-1", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-b", "p256-b", "auth-b"); err != nil {
t.Fatal(err)
}
// A different user.
if err := AddPushSubscription("sub-2", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
if err := AddPushSubscription("sub-2", "quack", "https://push.example/ep-c", "p256-c", "auth-c"); err != nil {
t.Fatal(err)
}
@@ -26,7 +26,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
}
// Re-subscribing the same endpoint updates keys in place, not a new row.
if err := AddPushSubscription("sub-1", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep-a", "p256-a2", "auth-a2"); err != nil {
t.Fatal(err)
}
subs, _ = ListPushSubscriptions()
@@ -54,7 +54,7 @@ func TestPushSubscriptionLifecycle(t *testing.T) {
func TestTouchPushSubscriptionAdvancesWatermark(t *testing.T) {
setupTestDB(t)
if err := AddPushSubscription("sub-1", "https://push.example/ep", "p", "a"); err != nil {
if err := AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
t.Fatal(err)
}
subs, _ := ListPushSubscriptions()
+19 -6
View File
@@ -487,13 +487,26 @@ CREATE TABLE IF NOT EXISTS source_health (
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
-- digest watermark: the sender only counts stories seen after it. A user can
-- have several endpoints (phone, desktop) — each is notified independently.
--
-- user_localpart is the same identity one level down: user_sub is the OIDC
-- subject, but every adventure ownership join in this schema is keyed on the
-- Matrix localpart (see player_self_detail), and nothing else persists that
-- mapping outside a live session. It is captured at subscribe time so the
-- adventure alert sender — which runs on a ticker with no request to read a
-- session from — can answer "whose adventurer is this" at all.
--
-- last_adv_notified_at is the adventure alerts' own watermark, kept apart from
-- the digest's on purpose: the two senders run on different clocks and one
-- column would let each silently consume the other's backlog.
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_sub TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_notified_at INTEGER NOT NULL
endpoint TEXT PRIMARY KEY,
user_sub TEXT NOT NULL,
user_localpart TEXT NOT NULL DEFAULT '',
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_notified_at INTEGER NOT NULL,
last_adv_notified_at INTEGER NOT NULL DEFAULT 0
);
-- Privacy-preserving daily unique estimate. visitor is a salted hash of