Files
Pete/internal/storage/push_adventure.go
T
prosolis b19ab5eff0 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
2026-07-24 18:22:34 -07:00

87 lines
3.3 KiB
Go

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
}