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

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