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
+2
View File
@@ -146,6 +146,7 @@ type pageData struct {
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
AdvEnabled bool // the adventure section is configured (shows its alert categories in settings)
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
@@ -224,6 +225,7 @@ func (s *Server) base(r *http.Request) pageData {
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
AdvEnabled: s.adv.Enabled,
TTS: template.JS("null"),
}
if s.tts != nil {
+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
}
+283
View File
@@ -0,0 +1,283 @@
package web
import (
"encoding/json"
"strings"
"testing"
"time"
"pete/internal/storage"
)
// TestOwnerScopedAlertsNeverBroadcast is the security regression for this whole
// phase, and it is the one to keep if the rest are ever thinned out.
//
// Three of the four categories name somebody's adventurer. The sender resolves
// "which character belongs to this subscriber" from a join that can legitimately
// come back empty — a player off the board, an opt-out, a session with no
// username. If an empty answer ever fell through to "matches anything", every
// subscriber's phone would light up with a stranger's death, naming them.
func TestOwnerScopedAlertsNeverBroadcast(t *testing.T) {
owned := []storage.AdvEvent{
{EventType: "death", Subject: "Josie", Zone: "The Crypt"},
{EventType: "zone_clear", Subject: "Josie", Zone: "The Crypt"},
{EventType: "retreat", Subject: "Josie", Zone: "The Crypt"},
{EventType: "departure", Subject: "Josie", Zone: "The Crypt"},
{EventType: "mischief_contract", Subject: "Josie", Stakes: "500 gold"},
}
for _, ev := range owned {
// No resolvable owner at all.
if _, ok := advAlertFor(ev, ""); ok {
t.Errorf("%s matched with no owner resolved; that is a broadcast of a private event", ev.EventType)
}
// An owner, but somebody else's dispatch.
if _, ok := advAlertFor(ev, "Quack"); ok {
t.Errorf("%s about Josie matched a subscriber who plays Quack", ev.EventType)
}
// The actual owner.
if _, ok := advAlertFor(ev, "Josie"); !ok {
t.Errorf("%s about Josie did not match Josie", ev.EventType)
}
}
}
// TestSiegeAlertsAreRealmWide is the other half: the Siege is the one communal
// mechanic, so it must reach a subscriber whose ownership join came back empty —
// including someone who has never made a character at all.
func TestSiegeAlertsAreRealmWide(t *testing.T) {
for _, kind := range []string{"siege_start", "siege_win", "siege_loss"} {
a, ok := advAlertFor(storage.AdvEvent{EventType: kind, Boss: "The Hollow King"}, "")
if !ok {
t.Fatalf("%s did not match a subscriber with no character", kind)
}
if a.Category != advCatSiege {
t.Errorf("%s filed under %q, want %q", kind, a.Category, advCatSiege)
}
if a.URL != "/adventure/siege" {
t.Errorf("%s links to %q, want the war room", kind, a.URL)
}
}
}
// TestUntemplatedDispatchIsSilent pins that adding an event type upstream does
// not silently start paging people. W0 made an unknown event_type render as a
// neutral card rather than 400 — the right call for a *page*, and the wrong one
// for a phone. A dispatch nobody has written alert copy for gets no alert.
func TestUntemplatedDispatchIsSilent(t *testing.T) {
for _, kind := range []string{"treasure_found", "milestone", "arrival", "companion_hire", "brand_new_thing"} {
if _, ok := advAlertFor(storage.AdvEvent{EventType: kind, Subject: "Josie"}, "Josie"); ok {
t.Errorf("%s produced an alert; new event types must opt in, not opt out", kind)
}
}
}
// TestEndedRunAlertPrefersTheRunReport pins the landing page. A finished
// expedition has a report worth reading; a dispatch that predates the liveblog
// has no run id and must still land somewhere real rather than on /adventure/.
func TestEndedRunAlertPrefersTheRunReport(t *testing.T) {
withRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g1", RunID: "run-7"}
a, ok := advAlertFor(withRun, "Josie")
if !ok || a.URL != "/adventure/run/run-7" {
t.Errorf("url = %q, want the run report", a.URL)
}
noRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g2"}
a, ok = advAlertFor(noRun, "Josie")
if !ok || a.URL != "/adventure/g2" {
t.Errorf("url = %q, want the story permalink fallback", a.URL)
}
}
// TestAlertCopySurvivesAnEmptyZone guards the copy against the blank-noun defect
// that W2b hit twice: a fact is allowed to arrive with fields missing, and the
// result must still read as a sentence rather than "Josie fell in ".
func TestAlertCopySurvivesAnEmptyZone(t *testing.T) {
a, ok := advAlertFor(storage.AdvEvent{EventType: "death", Subject: "Josie"}, "Josie")
if !ok {
t.Fatal("death with no zone produced no alert")
}
if a.Title != "Josie fell in the dark" {
t.Errorf("title = %q; a missing zone must still read as a sentence", a.Title)
}
// Same for a siege with no boss name on the fact.
b, _ := advAlertFor(storage.AdvEvent{EventType: "siege_win"}, "")
if b.Body != "It went down. See how the town did." {
t.Errorf("body = %q; a nameless boss must still read as a sentence", b.Body)
}
}
// TestUnsignedContractKeepsItsSecret. The anonymity of an unsigned mischief
// contract is the mechanic — the buyer's name is the reward for surviving it. A
// notification that leaked it would hand the payoff to the target for free.
func TestUnsignedContractKeepsItsSecret(t *testing.T) {
signed, _ := advAlertFor(storage.AdvEvent{
EventType: "mischief_contract", Subject: "Josie", Opponent: "Quack",
}, "Josie")
if !strings.Contains(signed.Body, "Quack") {
t.Errorf("a signed contract hid its buyer: %q", signed.Body)
}
anon, _ := advAlertFor(storage.AdvEvent{EventType: "mischief_contract", Subject: "Josie"}, "Josie")
if strings.Contains(anon.Body, "Quack") || !strings.Contains(anon.Body, "isn't saying who") {
t.Errorf("an unsigned contract gave something away: %q", anon.Body)
}
}
// TestCategoriesDefaultToNothing pins the consent model. A user who turned on
// news notifications has not asked to be told about the game, and every way the
// preference can be missing or broken must mean "no".
func TestCategoriesDefaultToNothing(t *testing.T) {
s, _ := newAdvServer(t, "tok")
_ = s
for name, blob := range map[string]string{
"no prefs row at all": "",
"prefs but no key": `{"pete.weather.loc.v1":"\"London\""}`,
"key is not JSON": `{"pete.advPush.v1":"not json at all"}`,
"key is a JSON null": `{"pete.advPush.v1":null}`,
"all boxes unchecked": `{"pete.advPush.v1":"{\"siege\":false,\"run\":false}"}`,
} {
if blob != "" {
if err := storage.PutUserPrefs("sub-1", blob, "Josie", "j@example.com"); err != nil {
t.Fatal(err)
}
}
if got := advCategoriesFor("sub-1"); len(got) != 0 {
t.Errorf("%s: enabled %v, want nothing", name, got)
}
}
}
// TestCategoriesReadTheDoubleEncodedBlob. The client mirrors localStorage, whose
// values are strings, so the stored map arrives as JSON inside a JSON string.
// Both that shape and a bare object must parse — the bare form is what a
// hand-edited or migrated blob looks like.
func TestCategoriesReadTheDoubleEncodedBlob(t *testing.T) {
s, _ := newAdvServer(t, "tok")
_ = s
nested, _ := json.Marshal(map[string]string{
advPrefsKey: `{"siege":true,"run":true,"departure":false}`,
})
if err := storage.PutUserPrefs("sub-1", string(nested), "Josie", ""); err != nil {
t.Fatal(err)
}
got := advCategoriesFor("sub-1")
if !got[advCatSiege] || !got[advCatRun] {
t.Errorf("double-encoded blob parsed to %v, want siege+run", got)
}
if got[advCatDeparture] {
t.Error("an explicitly false category was treated as enabled")
}
bare := `{"pete.advPush.v1":{"contract":true}}`
if err := storage.PutUserPrefs("sub-2", bare, "Quack", ""); err != nil {
t.Fatal(err)
}
if got := advCategoriesFor("sub-2"); !got[advCatContract] {
t.Errorf("bare-object blob parsed to %v, want contract", got)
}
}
// TestFirstPassNeverReplaysTheBacklog is the deploy safety valve.
//
// Every subscription that exists when this ships carries watermark 0. Read
// literally that means "has never been told anything", and the first tick would
// page every subscriber for every dispatch Pete has ever stored — on the same
// pass, from a feature they never switched on. The seeding branch stamps those
// rows to now and sends nothing, so alerts begin at the next real dispatch.
func TestFirstPassNeverReplaysTheBacklog(t *testing.T) {
s, _ := newAdvServer(t, "tok")
// A subscriber with everything switched on and a character on the board —
// i.e. the person most exposed to a replay.
seedSubscriberWithEverythingOn(t, "sub-1", "josie", "Josie")
// A realm with history, all of it well before now.
old := time.Now().Add(-90 * 24 * time.Hour).Unix()
for i, kind := range []string{"death", "zone_clear", "siege_start", "departure"} {
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
GUID: string(rune('a'+i)) + "-guid", EventType: kind, Subject: "Josie",
Boss: "The Hollow King", Zone: "The Crypt", OccurredAt: old + int64(i),
}); err != nil {
t.Fatal(err)
}
}
before := time.Now().Unix()
// No push service is reachable from a test, so a send would surface as an
// error rather than silence. What this asserts is that we never get that far:
// the watermark is seeded and the pass returns having considered nobody.
s.sendAdventureAlerts()
subs, err := storage.ListPushSubscriptions()
if err != nil || len(subs) != 1 {
t.Fatalf("read back %d subscriptions (err %v), want 1", len(subs), err)
}
if subs[0].LastAdvNotifiedAt < before {
t.Fatalf("watermark = %d, want >= %d: a 0 watermark must be stamped to now, not read as 'tell them everything'",
subs[0].LastAdvNotifiedAt, before)
}
// And the pass after it is quiet, because everything in the realm is now
// behind the watermark.
s.sendAdventureAlerts()
subs, _ = storage.ListPushSubscriptions()
if subs[0].LastAdvNotifiedAt < before {
t.Error("second pass moved the watermark backwards")
}
}
// TestCategoriesOffStillAdvanceTheWatermark. Someone with push on but no
// adventure categories must not accumulate a backlog: switching a category on
// should start from that moment, not replay everything it was off for.
func TestCategoriesOffStillAdvanceTheWatermark(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if err := storage.AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
t.Fatal(err)
}
// Move off the seeding branch so the pass actually evaluates this row.
if err := storage.TouchAdvPushSubscription("https://push.example/ep", 1000); err != nil {
t.Fatal(err)
}
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
GUID: "g1", EventType: "siege_start", Boss: "The Hollow King", OccurredAt: 5000,
}); err != nil {
t.Fatal(err)
}
s.sendAdventureAlerts()
subs, _ := storage.ListPushSubscriptions()
if len(subs) != 1 || subs[0].LastAdvNotifiedAt != 5000 {
t.Fatalf("watermark = %d, want 5000: a subscriber with nothing enabled must still move past what they were not told",
subs[0].LastAdvNotifiedAt)
}
}
// seedSubscriberWithEverythingOn wires the full happy path: a push endpoint, a
// character on the board with an owner, and every alert category enabled.
func seedSubscriberWithEverythingOn(t *testing.T, sub, localpart, character string) {
t.Helper()
if err := storage.AddPushSubscription(sub, localpart, "https://push.example/"+sub, "p", "a"); err != nil {
t.Fatal(err)
}
blob, _ := json.Marshal(map[string]string{
advPrefsKey: `{"siege":true,"run":true,"departure":true,"contract":true}`,
})
if err := storage.PutUserPrefs(sub, string(blob), character, ""); err != nil {
t.Fatal(err)
}
if err := storage.ReplaceRoster([]storage.RosterEntry{{
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
}}, time.Now().Unix()); err != nil {
t.Fatal(err)
}
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
Localpart: localpart, Token: "tok-" + localpart,
}}, time.Now().Unix()); err != nil {
t.Fatal(err)
}
}
+3 -33
View File
@@ -180,40 +180,10 @@ func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bo
}
// disabledSourcesFor returns the set of source names a user has hidden, read
// from their stored prefs blob. The blob mirrors localStorage: a JSON object
// whose "pete.disabledSources.v1" value is itself a JSON string encoding a
// {sourceName: true} map. Any parse failure yields an empty (deny-nothing) set.
// from their stored prefs blob. Any parse failure yields an empty (deny-nothing)
// set — see userPrefBoolSet for the blob's shape and why it is double-encoded.
func disabledSourcesFor(sub 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["pete.disabledSources.v1"]
if !ok {
return out
}
// The value is normally a JSON *string* containing JSON; unwrap that first,
// but tolerate a bare object too.
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[name] = true
}
}
return out
return userPrefBoolSet(sub, "pete.disabledSources.v1")
}
// pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used
+5 -1
View File
@@ -47,7 +47,11 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid endpoint"}`, http.StatusBadRequest)
return
}
if err := storage.AddPushSubscription(u.Sub, req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
// The localpart is captured here because this is the only place the mapping is
// available: the alert sender runs on a ticker with no session to read. It may
// be empty for a session minted before the game economy existed — that costs
// only the owner-scoped alerts, and heals on the next re-subscribe.
if err := storage.AddPushSubscription(u.Sub, buyerLocalpart(u), req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil {
slog.Error("push: subscribe failed", "sub", u.Sub, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
+7 -1
View File
@@ -10,7 +10,13 @@
(function () {
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
// it's transient and per-device.
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"];
// pete.advPush.v1 is read by the *server* — the adventure alert sender parses
// it out of the stored blob to decide who to notify — where every other key
// here is only ever read back by a feature script. If it stops syncing, the
// toggles keep working locally and no alert is ever sent, which is the kind of
// failure nobody reports.
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off",
"pete.advPush.v1"];
var user = window.PETE_USER || null;
var serverPrefs = window.PETE_PREFS || null;
+63
View File
@@ -65,6 +65,68 @@
});
}
// ---- adventure alert categories -------------------------------------------
// Stored as a JSON string under a synced prefs key, because this is the one
// preference the *server* reads back: the alert sender parses the same blob to
// decide who to notify. Absent key means nothing enabled, on both sides.
var ADV_KEY = "pete.advPush.v1";
function advRead() {
try {
var raw = localStorage.getItem(ADV_KEY);
if (!raw) return {};
var v = JSON.parse(raw);
return v && typeof v === "object" ? v : {};
} catch (e) { return {}; }
}
function advWrite(set) {
try { localStorage.setItem(ADV_KEY, JSON.stringify(set)); } catch (e) {}
if (window.PetePrefs) window.PetePrefs.push();
}
// initAdvUI wires the category boxes. `on` is whether a push subscription
// currently exists — a category switch with no subscription behind it is wired
// to nothing, so the block stays hidden until there is one.
function initAdvUI(slot, on) {
var box = slot.querySelector("[data-adv-push]");
if (!box) return;
box.hidden = !on;
if (!on) return;
var note = box.querySelector("[data-adv-push-note]");
var inputs = box.querySelectorAll("[data-adv-cat]");
var set = advRead();
function paintNote() {
if (!note) return;
var n = 0;
for (var i = 0; i < inputs.length; i++) if (inputs[i].checked) n++;
note.textContent = n === 0
? "Nothing selected. You'll only get the news digest."
: (n === 1 ? "1 alert type on." : n + " alert types on.");
}
for (var i = 0; i < inputs.length; i++) {
(function (el) {
el.checked = !!set[el.getAttribute("data-adv-cat")];
// This function re-runs every time the subscription state changes; bind
// the listener once or a toggle-off-toggle-on writes the pref twice.
if (!el.dataset.advBound) {
el.dataset.advBound = "1";
el.addEventListener("change", function () {
var cur = advRead();
var key = el.getAttribute("data-adv-cat");
if (el.checked) cur[key] = true; else delete cur[key];
advWrite(cur);
paintNote();
});
}
})(inputs[i]);
}
paintNote();
}
// ---- settings-panel toggle ------------------------------------------------
function initPushUI() {
var slot = document.querySelector("[data-push-section]");
@@ -80,6 +142,7 @@
btn.setAttribute("aria-pressed", on ? "true" : "false");
btn.textContent = on ? "Notifications on" : "Turn on notifications";
if (note && text != null) note.textContent = text;
initAdvUI(slot, on);
}
function refresh() {
+29
View File
@@ -201,6 +201,35 @@
Turn on notifications
</button>
</div>
{{if .AdvEnabled}}
<!-- Adventure alerts. Revealed by pwa.js only once a subscription exists,
because a category toggle with no subscription behind it is a switch
wired to nothing. Every box starts unchecked: turning on news
notifications is not consent to be told about the game. -->
<div data-adv-push hidden class="mt-2 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
<div class="text-sm font-bold">⚔️ Adventure alerts</div>
<div class="text-xs text-[color:var(--ink)]/60">Pick what's worth a buzz. Off unless you say so.</div>
<div class="mt-2 space-y-1.5">
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="siege" class="mt-0.5 accent-[color:var(--accent)]">
<span><span class="font-semibold">The Siege</span>: when a world boss camps outside town, and when it's settled.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="run" class="mt-0.5 accent-[color:var(--accent)]">
<span><span class="font-semibold">Your expeditions</span>: cleared, backed out, or worse.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="departure" class="mt-0.5 accent-[color:var(--accent)]">
<span><span class="font-semibold">Wandering off</span>: your adventurer got bored and left without you.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="contract" class="mt-0.5 accent-[color:var(--accent)]">
<span><span class="font-semibold">Contracts on you</span>: somebody paid to have something sent after you.</span>
</label>
</div>
<div data-adv-push-note class="mt-2 text-xs text-[color:var(--ink)]/50"></div>
</div>
{{end}}
</div>
{{end}}{{end}}
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>