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
+420
View File
@@ -0,0 +1,420 @@
package web
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/storage"
)
// Adventure alerts: the only channel that reaches a player who isn't looking at
// the site or at Matrix.
//
// This rides entirely on facts gogobee is already sending. Every trigger below
// is a dispatch that already lands in adventure_events, so the whole phase is
// Pete-side and no new wire, event type or deploy ordering is involved.
//
// Two things separate it from the news digest it sits beside:
//
// - Its own clock. A digest is a summary and six hours late is fine; "the
// Siege has begun" six hours late is worse than silence, because the bout
// the alert is asking for may already be over.
// - Its own watermark (last_adv_notified_at). Sharing the digest's column
// would let each sender consume the other's backlog.
// advAlertInterval matches the roster tick that delivers the facts. Checking
// faster than they can arrive only burns queries.
const advAlertInterval = 2 * time.Minute
// advAlertScan caps how many dispatches one pass inspects. Generously above the
// realm's real rate (a busy day is tens of dispatches, not hundreds), so hitting
// it means something is wrong rather than something is busy — see the warning in
// sendAdventureAlerts.
const advAlertScan = 200
// advPrefsKey is where the client stores the per-category opt-ins, alongside the
// other synced preferences. Its value is a JSON object of {category: true}.
const advPrefsKey = "pete.advPush.v1"
// The alert categories. Every one is opt-in and defaults OFF: a user who turned
// on notifications did so for news, and quietly enrolling them in game alerts
// they never asked for is how a notification permission gets revoked for good.
//
// advCatSiege is realm-wide — it needs no ownership and reaches everyone who
// asked for it. The other three are owner-scoped and reach exactly one person.
const (
advCatSiege = "siege"
advCatRun = "run"
advCatDeparture = "departure"
advCatContract = "contract"
)
// advAlert is one notification a pass has decided to send.
type advAlert struct {
Category string
Title string
Body string
URL string
}
// StartAdventureAlerts launches the alert loop when push and the adventure
// section are both configured. Safe to call unconditionally.
func (s *Server) StartAdventureAlerts(ctx context.Context) {
if !s.cfg.Push.Enabled || s.auth == nil || !s.adv.Enabled {
return
}
go s.runAdventureAlerts(ctx)
}
func (s *Server) runAdventureAlerts(ctx context.Context) {
slog.Info("web: adventure alert sender started", "interval", advAlertInterval)
ticker := time.NewTicker(advAlertInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sendAdventureAlerts()
}
}
}
// sendAdventureAlerts runs one pass: read the dispatches nobody has been told
// about yet, and for each subscription decide whether any of them is that
// person's business.
func (s *Server) sendAdventureAlerts() {
subs, err := storage.ListPushSubscriptions()
if err != nil {
slog.Error("adv-push: list subscriptions failed", "err", err)
return
}
if len(subs) == 0 {
return
}
now := time.Now().Unix()
// A row written before adventure alerts existed carries watermark 0, which
// read literally means "has never been told about anything" and would page the
// subscriber for the entire history of the realm on the first tick after
// deploy. Stamp those to now and let them start from the next dispatch. This
// is the deploy-safety valve for the whole phase.
live := subs[:0]
for _, sub := range subs {
if sub.LastAdvNotifiedAt == 0 {
if err := storage.TouchAdvPushSubscription(sub.Endpoint, now); err != nil {
slog.Error("adv-push: seed watermark failed", "sub", sub.UserSub, "err", err)
}
continue
}
live = append(live, sub)
}
if len(live) == 0 {
return
}
// One scan serves every subscriber: read from the oldest watermark in the set
// and let each row be filtered per subscription below.
oldest := live[0].LastAdvNotifiedAt
for _, sub := range live {
if sub.LastAdvNotifiedAt < oldest {
oldest = sub.LastAdvNotifiedAt
}
}
events, err := storage.AdvEventsSince(oldest, advAlertScan)
if err != nil {
slog.Error("adv-push: scan dispatches failed", "err", err)
return
}
if len(events) == 0 {
return
}
if len(events) == advAlertScan {
// The scan was capped, so dispatches older than the window exist and are
// about to be skipped by the watermark advance below. Alerts are
// time-sensitive enough that dropping the tail is right; being quiet about
// it is not.
slog.Warn("adv-push: scan hit its cap; older dispatches skipped",
"cap", advAlertScan, "since", oldest)
}
// events[0] is the newest in the window (occurred_at DESC) and is where every
// watermark lands this pass, sent or not.
newest := events[0].OccurredAt
// Both lookups are per-user, and a user can hold several endpoints (phone,
// desktop). Cache within the pass so a two-device user costs one prefs parse
// and one ownership join rather than two.
catsByUser := make(map[string]map[string]bool)
nameByLocalpart := make(map[string]string)
sent, pruned := 0, 0
for _, sub := range live {
cats, ok := catsByUser[sub.UserSub]
if !ok {
cats = advCategoriesFor(sub.UserSub)
catsByUser[sub.UserSub] = cats
}
if len(cats) == 0 {
// Nothing enabled. Still advance, so turning a category on later starts
// from that moment rather than replaying the backlog it was off for.
s.touchAdv(sub.Endpoint, newest)
continue
}
// The ownership join is re-read every pass rather than trusted from the
// subscription row, and that is deliberate: an opt-out, a removal or a
// character change has to close the channel immediately, exactly as
// runReportLinkFor re-resolves rather than trusting a stored id.
mine := ""
if sub.Localpart != "" {
if cached, ok := nameByLocalpart[sub.Localpart]; ok {
mine = cached
} else {
if name, ok := storage.AdvCharacterForOwner(sub.Localpart); ok {
mine = name
}
nameByLocalpart[sub.Localpart] = mine
}
}
var top *advAlert
extra := 0
for _, ev := range events {
if ev.OccurredAt <= sub.LastAdvNotifiedAt {
continue // this endpoint has already been told
}
alert, ok := advAlertFor(ev, mine)
if !ok || !cats[alert.Category] {
continue
}
if top == nil {
// events are newest-first, so the first match is the newest match.
a := alert
top = &a
continue
}
extra++
}
if top == nil {
s.touchAdv(sub.Endpoint, newest)
continue
}
gone, err := s.sendPush(sub, buildAdvPayload(*top, extra))
if gone {
if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil {
slog.Error("adv-push: prune gone subscription failed", "err", derr)
} else {
pruned++
}
continue
}
if err != nil {
// Leave the watermark alone: a transient push-service failure should be
// retried on the next tick, not swallowed. The alert is at most
// advAlertInterval late, and the events are still in the window.
slog.Warn("adv-push: send failed", "sub", sub.UserSub, "err", err)
continue
}
s.touchAdv(sub.Endpoint, newest)
sent++
}
if sent > 0 || pruned > 0 {
slog.Info("adv-push: pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(live))
}
}
func (s *Server) touchAdv(endpoint string, ts int64) {
if err := storage.TouchAdvPushSubscription(endpoint, ts); err != nil {
slog.Error("adv-push: advance watermark failed", "endpoint", endpoint, "err", err)
}
}
// advAlertFor decides whether one dispatch is worth waking somebody for, and in
// what words. mine is the character name belonging to the subscriber, or "" when
// Pete cannot establish one — in which case only realm-wide alerts can match.
//
// Every owner-scoped branch compares against mine and nothing else. There is no
// path here where an unresolved owner falls through to a broadcast: a game alert
// naming somebody's adventurer, delivered to the wrong phone, is a privacy leak
// dressed as a feature.
func advAlertFor(ev storage.AdvEvent, mine string) (advAlert, bool) {
switch ev.EventType {
case "siege_start":
boss := ev.Boss
if boss == "" {
boss = "Something"
}
return advAlert{
Category: advCatSiege,
Title: "The Siege has begun",
Body: fmt.Sprintf("%s is camped outside the town. Everyone gets one bout a day.", boss),
URL: "/adventure/siege",
}, true
case "siege_win":
return advAlert{
Category: advCatSiege,
Title: "The town holds",
Body: siegeOutcomeBody(ev, "went down"),
URL: "/adventure/siege",
}, true
case "siege_loss":
return advAlert{
Category: advCatSiege,
Title: "The Siege is over",
Body: siegeOutcomeBody(ev, "walked away still standing"),
URL: "/adventure/siege",
}, true
}
// Everything below is about one person, so an unmatched subject ends it here.
if mine == "" || ev.Subject != mine {
return advAlert{}, false
}
switch ev.EventType {
case "death":
return advAlert{
Category: advCatRun,
Title: fmt.Sprintf("%s fell in %s", mine, orPlace(ev.Zone)),
Body: "The expedition is over. They'll need picking up.",
URL: advRunOrStoryURL(ev),
}, true
case "zone_clear":
return advAlert{
Category: advCatRun,
Title: fmt.Sprintf("%s cleared %s", mine, orPlace(ev.Zone)),
Body: "They're through it and on the way home. Read how it went.",
URL: advRunOrStoryURL(ev),
}, true
case "retreat":
return advAlert{
Category: advCatRun,
Title: fmt.Sprintf("%s backed out of %s", mine, orPlace(ev.Zone)),
Body: "Everyone came home breathing. The run's finished either way.",
URL: advRunOrStoryURL(ev),
}, true
case "departure":
return advAlert{
Category: advCatDeparture,
Title: fmt.Sprintf("%s got bored and left", mine),
Body: fmt.Sprintf("No orders, no escort. They packed the cheap kit and set off into %s on their own.",
orPlace(ev.Zone)),
URL: advStoryURL(ev.GUID),
}, true
case "mischief_contract":
body := "Somebody paid to have something sent after them, and isn't saying who. Survive it and the money's theirs."
if ev.Opponent != "" {
body = fmt.Sprintf("%s paid for it and signed the thing. It's out there looking right now.", ev.Opponent)
}
return advAlert{
Category: advCatContract,
Title: fmt.Sprintf("There's a contract out on %s", mine),
Body: body,
URL: advStoryURL(ev.GUID),
}, true
}
return advAlert{}, false
}
// siegeOutcomeBody names the boss when the fact carried one. The verb differs by
// outcome, so it is the caller's.
func siegeOutcomeBody(ev storage.AdvEvent, verb string) string {
if ev.Boss == "" {
return fmt.Sprintf("It %s. See how the town did.", verb)
}
return fmt.Sprintf("%s %s. See how the town did.", ev.Boss, verb)
}
func orPlace(zone string) string {
if zone == "" {
return "the dark"
}
return zone
}
// advRunOrStoryURL prefers the run report, which is the richer landing place for
// an expedition that has just ended, and falls back to the dispatch permalink.
// Runs that predate the liveblog carry no run id and land on the story, which is
// what they have always done.
func advRunOrStoryURL(ev storage.AdvEvent) string {
if ev.RunID != "" {
return "/adventure/run/" + ev.RunID
}
return advStoryURL(ev.GUID)
}
func advStoryURL(guid string) string {
return "/adventure/" + guid
}
// buildAdvPayload renders the notification JSON the service worker expects. The
// tag is per-category so a Siege alert can't silently replace an unread alert
// about somebody's own adventurer on the lock screen.
func buildAdvPayload(a advAlert, extra int) []byte {
body := a.Body
if extra == 1 {
body += " Plus 1 other update."
} else if extra > 1 {
body += fmt.Sprintf(" Plus %d other updates.", extra)
}
b, _ := json.Marshal(map[string]string{
"title": a.Title,
"body": body,
"url": a.URL,
"tag": "pete-adv-" + a.Category,
})
return b
}
// advCategoriesFor returns the alert categories a user has switched on. An
// absent key, an unparseable blob or a user who has never opened the settings
// drawer all yield an empty set, which sends nothing — the safe direction for a
// channel that interrupts people.
func advCategoriesFor(sub string) map[string]bool {
return userPrefBoolSet(sub, advPrefsKey)
}
// userPrefBoolSet reads one {name: bool} map out of a user's stored preferences
// blob, keeping only the true entries.
//
// The blob mirrors localStorage: a JSON object whose values are themselves JSON
// *strings*. That double encoding is the client's doing, not ours, so unwrap it
// — while tolerating a bare object, since a hand-written or migrated blob may
// carry one. Any parse failure yields an empty set, and every caller treats an
// empty set as "no", so a corrupt blob costs a feature rather than misfiring it.
func userPrefBoolSet(sub, key string) map[string]bool {
out := map[string]bool{}
blob, err := storage.GetUserPrefs(sub)
if err != nil || blob == "" {
return out
}
var prefs map[string]json.RawMessage
if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
return out
}
raw, ok := prefs[key]
if !ok {
return out
}
inner := []byte(raw)
var asStr string
if err := json.Unmarshal(raw, &asStr); err == nil {
inner = []byte(asStr)
}
var set map[string]bool
if err := json.Unmarshal(inner, &set); err != nil {
return out
}
for name, on := range set {
if on {
out[strings.TrimSpace(name)] = true
}
}
return out
}