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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user