mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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,
|
||||
|
||||
224
internal/peteclient/client.go
Normal file
224
internal/peteclient/client.go
Normal file
@@ -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:<hash>:<ts>"
|
||||
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
|
||||
}
|
||||
91
internal/peteclient/client_test.go
Normal file
91
internal/peteclient/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
233
internal/plugin/bootstrap_pete_news.go
Normal file
233
internal/plugin/bootstrap_pete_news.go
Normal 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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
225
internal/plugin/pete.go
Normal 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 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, "")
|
||||
}
|
||||
139
internal/plugin/pete_test.go
Normal file
139
internal/plugin/pete_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
9
main.go
9
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)
|
||||
|
||||
Reference in New Issue
Block a user