mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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
226 lines
7.6 KiB
Go
226 lines
7.6 KiB
Go
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, "")
|
||
}
|