diff --git a/internal/db/db.go b/internal/db/db.go index 1e376d9..ccd2342 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -947,6 +947,38 @@ CREATE TABLE IF NOT EXISTS shade_optout ( user_id TEXT PRIMARY KEY ); +-- Pete adventure-news seam: durable outbound queue of game-event facts sent to +-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a +-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so +-- retries and duplicate emits collapse to one row. +CREATE TABLE IF NOT EXISTS pete_emit_queue ( + guid TEXT PRIMARY KEY, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + sent_at INTEGER +); + +-- Players who opted out of being named in Pete's adventure news. Enforced at +-- emit time (anonymize, never delete). Mirrors shade_optout. +CREATE TABLE IF NOT EXISTS news_optout ( + user_id TEXT PRIMARY KEY, + opted_out_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Realm-first ledger for adventure news: the first (kind,target) occurrence +-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so +-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone +-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a +-- fresh deploy doesn't re-flag historically-cleared content as first-ever. +CREATE TABLE IF NOT EXISTS news_realm_firsts ( + kind TEXT NOT NULL, + target TEXT NOT NULL, + first_at INTEGER NOT NULL, + PRIMARY KEY (kind, target) +); + -- Birthdays CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go new file mode 100644 index 0000000..d05dda6 --- /dev/null +++ b/internal/peteclient/client.go @@ -0,0 +1,224 @@ +// Package peteclient is gogobee's outbound seam to the Pete news bot. +// +// gogobee is the source of game-event *facts* and owns delivery; Pete owns +// voice, authoring, and publishing. This package carries structured facts (not +// prose) to Pete's ingest endpoint over the tailnet, bearer-authed. +// +// Delivery is durable: Emit writes the fact to a SQLite queue and returns +// immediately, so a game-loop hook never blocks on the network and a Pete +// restart loses nothing. A background sender drains the queue with retry. +// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops. +package peteclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "gogobee/internal/db" +) + +// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be +// character names only (never Matrix handles); Actors is the allow-list of the +// only names permitted to appear in Pete's rendered output. See +// pete_adventure_news_voice.md for the field contract. +type Fact struct { + GUID string `json:"guid"` // stable idempotency key, e.g. "death::" + EventType string `json:"event_type"` + Tier string `json:"tier"` // "priority" | "bulletin" + Actors []string `json:"actors"` + Subject string `json:"subject,omitempty"` + Opponent string `json:"opponent,omitempty"` + Boss string `json:"boss,omitempty"` + Zone string `json:"zone,omitempty"` + Region string `json:"region,omitempty"` + Level int `json:"level,omitempty"` + Count int `json:"count,omitempty"` + Outcome string `json:"outcome,omitempty"` + Stakes string `json:"stakes,omitempty"` + ClassRace string `json:"class_race,omitempty"` + Milestone string `json:"milestone,omitempty"` + OccurredAt int64 `json:"occurred_at"` + NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push +} + +// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing +// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at +// the source. +type Config struct { + IngestURL string + Token string + Enabled bool +} + +// Client is the transport half. It is a package singleton initialized by Init, +// so emit hooks scattered across plugins (and free functions like +// markAdventureDead) can call Emit without threading a handle through. +type Client struct { + cfg Config + http *http.Client +} + +var std *Client + +// Tuning for the background sender. +const ( + senderTick = 15 * time.Second + senderBatch = 20 + maxAttempts = 8 // ~ up to a few hours of backoff, then park + backoffBase = 30 * time.Second + backoffCapSec = 3600 + sendTimeout = 15 * time.Second +) + +// Init wires the singleton from the environment. Mirrors the per-plugin config +// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS. +func Init() { + cfg := Config{ + IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"), + Token: os.Getenv("PETE_INGEST_TOKEN"), + Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"), + } + if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") { + slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling") + cfg.Enabled = false + } + std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}} + if cfg.Enabled { + slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL) + } else { + slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)") + } +} + +// Enabled reports whether emission is on. Callers can skip building an +// (expensive) fact when it would be dropped anyway. +func Enabled() bool { return std != nil && std.cfg.Enabled } + +// Emit durably queues a fact for delivery to Pete. It never blocks on the +// network. A no-op (but safe) when the seam is disabled or the GUID was already +// queued — idempotency is on the GUID primary key. +func Emit(f Fact) { + if !Enabled() { + return + } + if f.GUID == "" { + slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType) + return + } + payload, err := json.Marshal(f) + if err != nil { + slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err) + return + } + // OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped. + db.Exec("pete emit enqueue", + `INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at) + VALUES (?, ?, unixepoch(), 0, 0)`, + f.GUID, string(payload)) +} + +// StartSender launches the background drain loop. It runs until ctx is +// canceled. Safe to call when disabled — it simply idles. +func StartSender(ctx context.Context) { + if std == nil { + return + } + go func() { + t := time.NewTicker(senderTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if std.cfg.Enabled { + std.drain(ctx) + } + } + } + }() +} + +// drain sends up to senderBatch due rows, one at a time. +func (c *Client) drain(ctx context.Context) { + rows, err := db.Get().Query( + `SELECT guid, payload FROM pete_emit_queue + WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch() + ORDER BY created_at LIMIT ?`, + maxAttempts, senderBatch) + if err != nil { + slog.Error("peteclient: drain query", "err", err) + return + } + type item struct{ guid, payload string } + var batch []item + for rows.Next() { + var it item + if err := rows.Scan(&it.guid, &it.payload); err != nil { + slog.Error("peteclient: drain scan", "err", err) + continue + } + batch = append(batch, it) + } + rows.Close() + + for _, it := range batch { + if ctx.Err() != nil { + return + } + if err := c.send(ctx, []byte(it.payload)); err != nil { + db.Exec("pete emit retry", + `UPDATE pete_emit_queue + SET attempts = attempts + 1, next_attempt_at = unixepoch() + ? + WHERE guid = ?`, + backoffSec(it.guid), it.guid) + slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err) + continue + } + db.Exec("pete emit sent", + `UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid) + } +} + +// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the +// bearer-POST pattern in email_nag.go:sendCode. +func (c *Client) send(ctx context.Context, payload []byte) error { + url := c.cfg.IngestURL + "/api/ingest/adventure" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode/100 != 2 { + return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// backoffSec computes the retry delay for a row. It re-reads the current attempt +// count so the delay grows geometrically without needing it passed in. +func backoffSec(guid string) int { + var attempts int + _ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts) + // attempts is the count *before* this failure's increment; delay off it. + delay := int(backoffBase.Seconds()) << attempts + if delay > backoffCapSec { + delay = backoffCapSec + } + return delay +} diff --git a/internal/peteclient/client_test.go b/internal/peteclient/client_test.go new file mode 100644 index 0000000..f09a57b --- /dev/null +++ b/internal/peteclient/client_test.go @@ -0,0 +1,91 @@ +package peteclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "gogobee/internal/db" +) + +// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender +// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't +// re-send), while a duplicate GUID collapses to one delivery. +func TestEmitDrainRoundTrip(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var got []Fact + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ingest/adventure" { + t.Errorf("unexpected path %q", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + var f Fact + _ = json.Unmarshal(body, &f) + mu.Lock() + got = append(got, f) + gotAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()} + + Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) + Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid + + std.drain(context.Background()) + + mu.Lock() + defer mu.Unlock() + if len(got) != 1 { + t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got)) + } + if got[0].Subject != "Brannigan" { + t.Errorf("subject = %q", got[0].Subject) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth header = %q", gotAuth) + } + + var pending int + if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil { + t.Fatal(err) + } + if pending != 0 { + t.Errorf("%d rows still pending after successful drain", pending) + } + + // A second drain re-sends nothing. + std.drain(context.Background()) + if len(got) != 1 { + t.Errorf("re-drained a sent row: %d deliveries", len(got)) + } +} + +// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op. +func TestEmitDisabledNoQueue(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient} + Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1}) + var n int + // db.Init is a process singleton, so this may share state with other tests; + // scope the check to this fact's guid. + if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("disabled Emit queued %d rows, want 0", n) + } +} diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index fdd353c..c8cff9e 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -9,6 +9,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" @@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) { } func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) { - _, err := d.Exec( + res, err := d.Exec( `INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`, string(userID), achievementID, ) @@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err) return } + // Only a genuinely new grant is news; a re-check conflict (rows==0) is not. + if n, _ := res.RowsAffected(); n == 0 { + return + } slog.Info("achievements: granted", "user", userID, "achievement", achievementID) + p.emitMilestoneNews(d, userID, achievementID) +} + +// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements +// only — rarity-gated to a single holder (per the news selection thresholds) so +// routine unlocks don't flood the feed. Character name only; no-op unless the +// seam is enabled. +func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) { + var holders int + if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 { + return + } + name := charName(userID) + if name == "" { + return + } + label := achievementID + for _, a := range p.achievements { + if a.ID == achievementID { + label = a.Name + break + } + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("achv:%s:%s", userHash(userID), achievementID), + EventType: "milestone", + Tier: "bulletin", + Subject: name, + Milestone: label, + OccurredAt: ts, + }, userID, "") } func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 990cf41..6d23ef6 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -184,6 +184,7 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"}, {Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"}, {Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"}, + {Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"}, } } @@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error { // arrival roll. Both idempotent via JobCompleted gates. bootstrapCasterSpellBackfill() bootstrapGrantStarterPet() + // Pete adventure-news cold-start: replay the back-catalogue (realm-firsts, + // deaths, single-holder achievements) the first boot the seam is live, so + // launch doesn't open onto an empty section. One-shot, kept (see gap #7). + p.bootstrapPeteNewsBackfill() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which @@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "rivals") { return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals")) } + if p.IsCommand(ctx.Body, "news") { + return p.handleNewsCmd(ctx) + } // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { diff --git a/internal/plugin/adventure_duel.go b/internal/plugin/adventure_duel.go index 5ddc7fe..e5aff19 100644 --- a/internal/plugin/adventure_duel.go +++ b/internal/plugin/adventure_duel.go @@ -32,6 +32,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "github.com/google/uuid" "maunium.net/go/mautrix/id" @@ -613,6 +614,21 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c upsertRivalRecord(winnerID, loserID, true) upsertRivalRecord(loserID, winnerID, false) + // One BULLETIN dispatch per settled duel (from the winner's side, so it + // fires once). Character names only; no-op unless the seam is enabled. + if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" { + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("rival:%s:%s:%d", userHash(winnerID), userHash(loserID), ts), + EventType: "rival_result", + Tier: "bulletin", + Subject: wn, + Opponent: ln, + Outcome: "won", + OccurredAt: ts, + }, winnerID, loserID) + } + p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive) return nil } diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go new file mode 100644 index 0000000..05370a9 --- /dev/null +++ b/internal/plugin/bootstrap_pete_news.go @@ -0,0 +1,233 @@ +package plugin + +import ( + "fmt" + "log/slog" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit +// path the first time the seam comes up enabled, so a launch doesn't open onto +// an empty section. It covers the three tractable, high-signal sources: +// +// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This +// also SEEDS news_realm_firsts, so a later live clear of an already-cleared +// zone tiers as a BULLETIN repeat, not a mis-announced "first ever". +// 2. Deaths — every player_meta row that carries a death (graveyard content). +// 3. Single-holder achievements — rarity-gated to one holder, matching the live +// milestone rule so routine unlocks don't flood the backlog. +// +// Backfilled facts are backdated to when they happened and marked NoPush (no +// web-push spam on launch). GUIDs are deterministic (keyed on the historical +// timestamp), so a re-run — or a later live emit of the same event — dedups on +// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh +// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7. +func (p *AdventurePlugin) bootstrapPeteNewsBackfill() { + const jobName = "pete_news_backfill_v1" + if db.JobCompleted(jobName, "once") { + return + } + // The backfill IS the launch content, so it should fire the first boot the + // seam is actually live — not get silently consumed on a boot where emission + // is off. Leave the job unmarked until both switches are on. + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + + firsts := p.backfillZoneFirsts() + deaths := backfillDeaths() + achv := p.backfillSoloAchievements() + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: pete news backfill complete", + "zone_firsts", firsts, "deaths", deaths, "achievements", achv) +} + +// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY +// realm-first dispatch per zone, attributed to its earliest boss-defeating +// clearer. SQLite returns the user_id from the same row as MIN(completed_at) +// (bare-column min/max rule), so the (zone, first clearer, time) triple is +// consistent. Returns the count emitted. +func (p *AdventurePlugin) backfillZoneFirsts() int { + rows, err := db.Get().Query( + `SELECT zone_id, user_id, MIN(completed_at) + FROM dnd_zone_run + WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0 + GROUP BY zone_id`) + if err != nil { + slog.Error("backfill: zone-firsts query", "err", err) + return 0 + } + type first struct { + zoneID, userID, completedAt string + } + var firsts []first + for rows.Next() { + var f first + if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { + slog.Error("backfill: zone-firsts scan", "err", err) + continue + } + firsts = append(firsts, f) + } + rows.Close() + + n := 0 + for _, f := range firsts { + uid := id.UserID(f.userID) + // Seed the ledger regardless of whether we can render a dispatch, so the + // realm-first tiering is correct even for an unnamed straggler. + claimRealmFirst("zone", f.zoneID) + + name := charName(uid) + if name == "" { + continue + } + ts, ok := parseSQLiteTime(f.completedAt) + if !ok { + continue + } + zone := zoneOrFallback(ZoneID(f.zoneID)) + lvl := 0 + if dc, _ := LoadDnDCharacter(uid); dc != nil { + lvl = dc.Level + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(uid), f.zoneID, ts.Unix()), + EventType: "zone_first", + Tier: "priority", + Subject: name, + Zone: zone.Display, + Boss: zone.Boss.Name, + Level: lvl, + Outcome: "cleared", + OccurredAt: ts.Unix(), + NoPush: true, + }, uid, "") + n++ + } + return n +} + +// backfillDeaths emits one death dispatch per player_meta death record. Deaths +// are day-granular (last_death_date), so the dispatch is backdated to that day. +func backfillDeaths() int { + rows, err := db.Get().Query( + `SELECT user_id, last_death_date, death_location + FROM player_meta + WHERE last_death_date != ''`) + if err != nil { + slog.Error("backfill: deaths query", "err", err) + return 0 + } + type death struct { + userID, date, location string + } + var deaths []death + for rows.Next() { + var d death + if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil { + slog.Error("backfill: deaths scan", "err", err) + continue + } + deaths = append(deaths, d) + } + rows.Close() + + n := 0 + for _, d := range deaths { + uid := id.UserID(d.userID) + name := charName(uid) + if name == "" { + continue + } + day, err := time.Parse("2006-01-02", d.date) + if err != nil { + continue + } + lvl := 0 + if dc, _ := LoadDnDCharacter(uid); dc != nil { + lvl = dc.Level + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("death:%s:%s", userHash(uid), d.date), + EventType: "death", + Tier: "priority", + Subject: name, + Zone: d.location, + Level: lvl, + Outcome: "lost", + OccurredAt: day.UTC().Unix(), + NoPush: true, + }, uid, "") + n++ + } + return n +} + +// backfillSoloAchievements emits a milestone dispatch for every achievement held +// by exactly one player — the same rarity gate the live path uses — so the +// backlog carries the genuinely distinctive unlocks, not routine ones. +func (p *AdventurePlugin) backfillSoloAchievements() int { + rows, err := db.Get().Query( + `SELECT achievement_id, user_id, unlocked_at + FROM achievements + GROUP BY achievement_id + HAVING COUNT(*) = 1`) + if err != nil { + slog.Error("backfill: achievements query", "err", err) + return 0 + } + type solo struct { + achID, userID string + unlockedAt int64 + } + var solos []solo + for rows.Next() { + var s solo + if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil { + slog.Error("backfill: achievements scan", "err", err) + continue + } + solos = append(solos, s) + } + rows.Close() + + n := 0 + for _, s := range solos { + uid := id.UserID(s.userID) + name := charName(uid) + if name == "" { + continue + } + label := s.achID + if p.achievements != nil { + for _, a := range p.achievements.achievements { + if a.ID == s.achID { + label = a.Name + break + } + } + } + ts := s.unlockedAt + if ts == 0 { + ts = nowUnix() + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("achv:%s:%s", userHash(uid), s.achID), + EventType: "milestone", + Tier: "bulletin", + Subject: name, + Milestone: label, + OccurredAt: ts, + NoPush: true, + }, uid, "") + n++ + } + return n +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 792ca7f..1c621b2 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -6,6 +6,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) @@ -575,4 +576,30 @@ func markAdventureDead(userID id.UserID, source, location string) { if err := saveAdvCharacter(char); err != nil { slog.Error("dnd: kill on combat loss", "user", userID, "err", err) } + emitDeathNews(userID, location) +} + +// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op +// unless the seam is enabled. Uses the character name (never the Matrix handle); +// skips silently if the name is unknown. +func emitDeathNews(userID id.UserID, location string) { + name := charName(userID) + if name == "" { + return + } + lvl := 0 + if dc, _ := LoadDnDCharacter(userID); dc != nil { + lvl = dc.Level + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("death:%s:%d", userHash(userID), ts), + EventType: "death", + Tier: "priority", + Subject: name, + Zone: location, + Level: lvl, + Outcome: "lost", + OccurredAt: ts, + }, userID, "") } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index f37f319..051cab8 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -177,6 +177,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" { lines = append(lines, sl) } + // News: boss down means the zone is cleared. Realm-first → PRIORITY, + // repeat → BULLETIN. No-op unless the Pete seam is enabled. + emitZoneClearNews(userID, exp) return lines } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index 25e5c20..f0914db 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -9,6 +9,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) @@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { // Idempotent — !setup confirm after a respec wipe will repopulate. _ = ensureSpellsForCharacter(c) + // A new character walked through the gates — file a welcome dispatch to + // Pete. GUID keyed on the user alone so a later respec/re-confirm can't + // re-announce the same arrival. No-op unless the seam is enabled. + if name := charName(ctx.Sender); name != "" { + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: "arrival:" + userHash(ctx.Sender), + EventType: "arrival", + Tier: "bulletin", + Subject: name, + ClassRace: classRaceLabel(c), + Level: c.Level, + OccurredAt: ts, + }, ctx.Sender, "") + } + return p.SendDM(ctx.Sender, renderSetupComplete(c)) } diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go new file mode 100644 index 0000000..70faf7d --- /dev/null +++ b/internal/plugin/pete.go @@ -0,0 +1,225 @@ +package plugin + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// This file is gogobee's game-side entry to Pete's adventure news. Plugins call +// emitFact at event chokepoints; it enforces the kill-switch + per-player +// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only +// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md. + +const ( + newsEnabledKey = "adventure_news_enabled" + // foreverTTL makes CacheGet a persistent flag: the row never "expires". + foreverTTL = 100 * 365 * 24 * 3600 + anonName = "an adventurer" +) + +// newsEmissionOn is the runtime kill-switch (default on). An operator flips it +// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level +// master switch checked separately by peteclient.Enabled. +func newsEmissionOn() bool { + return db.CacheGet(newsEnabledKey, foreverTTL) != "0" +} + +// setNewsEmission persists the runtime kill-switch. +func setNewsEmission(on bool) { + v := "1" + if !on { + v = "0" + } + db.CacheSet(newsEnabledKey, v) +} + +// isNewsOptedOut reports whether a player asked not to be named in the news. +func isNewsOptedOut(userID id.UserID) bool { + var one int + err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one) + return err == nil +} + +// setNewsOptout records or clears a player's opt-out. Opting out anonymizes +// their name in future dispatches ("an adventurer…"); it does not stop events +// being reported. Idempotent either direction. +func setNewsOptout(userID id.UserID, out bool) { + if out { + db.Exec("news optout set", + `INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`, + string(userID)) + return + } + db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID)) +} + +// userHash is a stable, one-way digest of a Matrix user id. It keys per-player +// event GUIDs (which become PUBLIC permalink paths on Pete) WITHOUT leaking the +// Matrix handle. Not reversible, stable across restarts. +func userHash(userID id.UserID) string { + sum := sha256.Sum256([]byte(userID)) + return hex.EncodeToString(sum[:6]) // 12 hex chars — ample for 4–thousands of players +} + +// charName returns a player's in-game character name, never their Matrix handle. +// Empty when unknown — callers skip the emit rather than fall back to a handle. +func charName(userID id.UserID) string { + name, _ := loadDisplayName(userID) + return strings.TrimSpace(name) +} + +// classRaceLabel renders a character's race + class for the arrival fact, e.g. +// "Elf Ranger". +func classRaceLabel(c *DnDCharacter) string { + ri, _ := raceInfo(c.Race) + ci, _ := classInfo(c.Class) + return strings.TrimSpace(ri.Display + " " + ci.Display) +} + +// emitFact is the single game-side entry to Pete's news. It enforces the runtime +// kill-switch and per-player opt-out (anonymize the name, never drop the event), +// derives the actors allow-list from the FINAL names (so Pete's fact-guard and +// the rendered content agree), then durably queues the fact. +// +// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either +// may be empty for realm-level events). They are used ONLY for the opt-out +// lookup and are never sent. +func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + if subjectUser != "" && isNewsOptedOut(subjectUser) { + f.Subject = anonName + } + if opponentUser != "" && isNewsOptedOut(opponentUser) { + f.Opponent = anonName + } + var actors []string + for _, n := range []string{f.Subject, f.Opponent} { + if n != "" { + actors = append(actors, n) + } + } + f.Actors = actors + peteclient.Emit(f) +} + +// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's +// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room +// so the effect is visible where it was invoked. +// +// !news → show your status + what the command does +// !news optout → anonymize your name in future dispatches +// !news optin → be named again +// !news on|off → (admin) master kill-switch for all emission +func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error { + arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news"))) + switch arg { + case "optout", "opt-out", "anonymize", "anon": + setNewsOptout(ctx.Sender, true) + return p.SendReply(ctx.RoomID, ctx.EventID, + "Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.") + case "optin", "opt-in": + setNewsOptout(ctx.Sender, false) + return p.SendReply(ctx.RoomID, ctx.EventID, + "You're back on the record — Pete will use your character name in future dispatches.") + case "on", "enable": + if !p.IsAdmin(ctx.Sender) { + return nil + } + setNewsEmission(true) + return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.") + case "off", "disable": + if !p.IsAdmin(ctx.Sender) { + return nil + } + setNewsEmission(false) + return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.") + case "", "status", "help": + named := "using your character name" + if isNewsOptedOut(ctx.Sender) { + named = "anonymized (\"an adventurer\")" + } + msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" + + "You are currently " + named + ".\n" + + "`!news optout` to be anonymized · `!news optin` to be named again." + if p.IsAdmin(ctx.Sender) { + state := "ON" + if !newsEmissionOn() { + state = "OFF" + } + msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle." + } + return p.SendReply(ctx.RoomID, ctx.EventID, msg) + default: + return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.") + } +} + +// nowUnix is the event timestamp helper (kept in one place so the emit sites +// read uniformly). +func nowUnix() int64 { return time.Now().Unix() } + +// claimRealmFirst records the first occurrence of a (kind,target) across the +// realm and reports whether THIS call was the first — true exactly once. Used to +// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill +// pre-seeds this ledger so historical firsts aren't re-announced after a deploy. +func claimRealmFirst(kind, target string) bool { + res := db.ExecResult("news realm-first claim", + `INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`, + kind, target) + if res == nil { + return false + } + n, err := res.RowsAffected() + return err == nil && n > 0 +} + +// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The +// realm's first clear of a zone is PRIORITY; later clears are BULLETIN. Character +// name only; no-op unless the seam is enabled. +func emitZoneClearNews(userID id.UserID, exp *Expedition) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + name := charName(userID) + if name == "" { + return + } + zone := zoneOrFallback(exp.ZoneID) + region := "" + if IsMultiRegionZone(exp.ZoneID) { + if r, ok := CurrentRegion(exp); ok { + region = r.Name + } + } + lvl := 0 + if dc, _ := LoadDnDCharacter(userID); dc != nil { + lvl = dc.Level + } + tier := "bulletin" + if claimRealmFirst("zone", string(exp.ZoneID)) { + tier = "priority" + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(userID), exp.ZoneID, ts), + EventType: "zone_first", + Tier: tier, + Subject: name, + Zone: zone.Display, + Region: region, + Boss: zone.Boss.Name, + Level: lvl, + Outcome: "cleared", + OccurredAt: ts, + }, userID, "") +} diff --git a/internal/plugin/pete_test.go b/internal/plugin/pete_test.go new file mode 100644 index 0000000..ea6a11c --- /dev/null +++ b/internal/plugin/pete_test.go @@ -0,0 +1,139 @@ +package plugin + +import ( + "os" + "testing" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// enablePeteSeam turns on the emit seam for a test and restores it to disabled +// afterwards, so a leaked-enabled singleton can't affect other tests. +func enablePeteSeam(t *testing.T) { + t.Helper() + os.Setenv("FEATURE_PETE_NEWS", "true") + os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0") + os.Setenv("PETE_INGEST_TOKEN", "tok") + peteclient.Init() + t.Cleanup(func() { + os.Unsetenv("FEATURE_PETE_NEWS") + os.Unsetenv("PETE_INGEST_URL") + os.Unsetenv("PETE_INGEST_TOKEN") + peteclient.Init() + }) +} + +func queuedCount(t *testing.T, likeGUID string) int { + t.Helper() + var n int + if err := db.Get().QueryRow( + `SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil { + t.Fatalf("queue count: %v", err) + } + return n +} + +// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them, +// both idempotently. +func TestNewsOptoutRoundTrip(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + u := id.UserID("@zapp:example.org") + if isNewsOptedOut(u) { + t.Fatal("fresh player should not be opted out") + } + + setNewsOptout(u, true) + if !isNewsOptedOut(u) { + t.Error("opt-out not recorded") + } + setNewsOptout(u, true) // idempotent + if !isNewsOptedOut(u) { + t.Error("second opt-out flipped state") + } + + setNewsOptout(u, false) + if isNewsOptedOut(u) { + t.Error("opt-in did not clear") + } + setNewsOptout(u, false) // idempotent + if isNewsOptedOut(u) { + t.Error("second opt-in flipped state") + } +} + +// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone +// realm-firsts through the emit queue, seeds the realm-first ledger, and is +// idempotent on a second boot. +func TestPeteNewsBackfill(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + enablePeteSeam(t) + + // A named, dead adventurer. + db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location) + VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge") + // A single-holder achievement. + db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at) + VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000) + // A completed, boss-defeated zone run → realm-first. + db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at) + VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00") + + p := &AdventurePlugin{} + p.bootstrapPeteNewsBackfill() + + if got := queuedCount(t, "death:%"); got != 1 { + t.Errorf("death dispatches queued = %d, want 1", got) + } + if got := queuedCount(t, "achv:%"); got != 1 { + t.Errorf("achievement dispatches queued = %d, want 1", got) + } + if got := queuedCount(t, "zone:%"); got != 1 { + t.Errorf("zone-first dispatches queued = %d, want 1", got) + } + // Realm-first ledger seeded so a later live clear tiers as a repeat. + if claimRealmFirst("zone", "goblin_warren") { + t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever") + } + + // Second boot: job gate holds, nothing re-queued. + p.bootstrapPeteNewsBackfill() + if got := queuedCount(t, "%"); got != 3 { + t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got) + } +} + +// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip. +func TestNewsEmissionKillSwitch(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + if !newsEmissionOn() { + t.Error("emission should default ON") + } + setNewsEmission(false) + if newsEmissionOn() { + t.Error("emission still ON after disable") + } + setNewsEmission(true) + if !newsEmissionOn() { + t.Error("emission still OFF after enable") + } +} diff --git a/main.go b/main.go index 54d89a0..4ba7f02 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "gogobee/internal/bot" "gogobee/internal/db" "gogobee/internal/dreamclient" + "gogobee/internal/peteclient" "gogobee/internal/plugin" "gogobee/internal/util" "gogobee/internal/version" @@ -55,6 +56,11 @@ func main() { } db.RecordStartup(version.Version, version.Commit) + // Pete adventure-news seam: wire the outbound fact client from the + // environment (no-op unless FEATURE_PETE_NEWS=true). The background sender + // is started once the run context exists (below). + peteclient.Init() + // Create Matrix session. AUTH_MODE selects the transport: // masdevice (default) — MAS OAuth device grant over /sync. // appservice — as_token auth over Synapse transaction push. @@ -97,6 +103,9 @@ func main() { cancel() }() + // Drain the Pete adventure-news emit queue in the background until shutdown. + peteclient.StartSender(ctx) + // Show the bot online from boot (appservice mode; no-op under masdevice, where // /sync refreshes presence implicitly). Safe before the listener — outbound only. sess.StartPresence(ctx)