Pete Adventure News: emit seam, !news command, cold-start backfill

gogobee's game-side of Pete's push-based Adventure news feed. Emits
structured event *facts* (not prose) to Pete's ingest endpoint over a
durable queue; Pete owns voice/authoring/publishing.

- internal/peteclient: durable pete_emit_queue + retry sender; Fact
  payload; FEATURE_PETE_NEWS master switch.
- pete.go: emitFact enforces runtime kill-switch + per-player opt-out
  (anonymize, never drop); zone-clear + realm-first tiering; !news
  command (player optout/optin, admin on/off).
- bootstrap_pete_news.go: cold-start backfill replays deaths +
  single-holder achievements + zone realm-firsts, backdated + NoPush,
  idempotent, fires only once the seam is live; seeds news_realm_firsts.
- Emit sites: death, zone_first, rival_result, milestone, arrival.
- db.go: pete_emit_queue, news_optout (+opted_out_at), news_realm_firsts.

Unshipped. Deploy Pete first, then set FEATURE_PETE_NEWS + ingest env.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-11 00:53:25 -07:00
parent 0c4c4757d3
commit b42beec348
13 changed files with 1062 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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") {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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, "")
}

View File

@@ -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
}

View File

@@ -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))
}

225
internal/plugin/pete.go Normal file
View File

@@ -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 4thousands 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, "")
}

View File

@@ -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")
}
}