mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
11 Commits
n6-rivalry
...
adventure-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d | ||
|
|
3f4b4ece5c | ||
|
|
a6f1de4e74 | ||
|
|
1a47a2fdee | ||
|
|
aaa45eab14 | ||
|
|
38a3693832 |
@@ -46,6 +46,8 @@ func main() {
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
@@ -90,9 +92,49 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *realUser != "" {
|
||||
if *dataDir == "" {
|
||||
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||
}
|
||||
if *party != 1 {
|
||||
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||
}
|
||||
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||
// at the live prod file or at ./data.
|
||||
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
fail("init runner:", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
uid := id.UserID(userTag)
|
||||
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||
if err != nil {
|
||||
fail("prepare real character:", err)
|
||||
}
|
||||
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||
if res != nil {
|
||||
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
if err != nil {
|
||||
fail("run:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// followerClasses expands -party / -party-classes into the class of each
|
||||
// follower seat. An explicit list must name every seat: a party of 3 whose
|
||||
// second follower silently defaulted to the leader's class would quietly
|
||||
|
||||
@@ -393,6 +393,14 @@ func runMigrations(d *sql.DB) error {
|
||||
// character save, so a monotonic false→true flag can't be clobbered.
|
||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
||||
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
|
||||
// XP that grantDnDXP used to drop instead accumulates here as cumulative
|
||||
// prestige XP. renown_xp is monotonic and written by an atomic += (the
|
||||
// journal_pages pattern), never the bulk character save; renown_level is
|
||||
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
|
||||
// there is no read-modify-write race and no second field to disagree.
|
||||
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -608,6 +616,15 @@ func RunMaintenance() {
|
||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||
// within a few hours, so anything still unsent after 30 days is dead
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
|
||||
@@ -939,6 +956,46 @@ 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)
|
||||
);
|
||||
|
||||
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||
CREATE TABLE IF NOT EXISTS news_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
|
||||
230
internal/peteclient/client.go
Normal file
230
internal/peteclient/client.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// 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:<token>:<ts>"; prefix == event_type
|
||||
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 {
|
||||
if ctx.Err() != nil {
|
||||
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||
// anything. Don't burn a durable retry attempt; the row is picked
|
||||
// up on the next boot's drain.
|
||||
return
|
||||
}
|
||||
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("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
@@ -1206,9 +1243,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return false
|
||||
},
|
||||
},
|
||||
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
|
||||
// against the derived Renown level (N7/B2); no event hook needed.
|
||||
{
|
||||
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
|
||||
// reached level, reading player_meta.renown_xp through the passed handle.
|
||||
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
|
||||
var xp int
|
||||
if err := d.QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp); err != nil {
|
||||
return false
|
||||
}
|
||||
return renownLevelFor(xp) >= level
|
||||
}
|
||||
|
||||
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||
|
||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||
|
||||
@@ -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") {
|
||||
@@ -646,6 +654,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -796,6 +805,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
|
||||
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
|
||||
// pot tax, so both the player's cut and the pot's rake grow proportionally.
|
||||
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
|
||||
run.Earnings = int64(float64(run.Earnings) * m)
|
||||
}
|
||||
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -118,6 +121,18 @@ type AdventureCharacter struct {
|
||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||
EpilogueCleared bool
|
||||
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
|
||||
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
|
||||
// bulk character save. RenownLevel() derives the prestige level from it.
|
||||
RenownXP int
|
||||
}
|
||||
|
||||
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
|
||||
func (c *AdventureCharacter) RenownLevel() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(c.RenownXP)
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -524,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
|
||||
// equipment) exists for userID, creating it only when absent. The auto-migration
|
||||
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
|
||||
// touching the legacy layer; without this, a brand-new player whose first-ever
|
||||
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
|
||||
// player_meta-less character that fails every legacy-layer command with
|
||||
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
|
||||
// idempotent: legacy players who already have player_meta keep their equipment
|
||||
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
|
||||
// and would otherwise duplicate their gear.
|
||||
func ensurePlayerMetaSeed(userID id.UserID) error {
|
||||
d := db.Get()
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
if err == nil {
|
||||
return nil // already seeded
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return createAdvCharacter(userID, localpartOf(userID))
|
||||
}
|
||||
|
||||
// localpartOf returns the mxid localpart (between @ and :) as a display-name
|
||||
// fallback — matches Base.DisplayName's offline behavior. The seed's display
|
||||
// name is overlaid by later player_meta upserts; this is just a sane default
|
||||
// for a character born without a Matrix client in reach.
|
||||
func localpartOf(userID id.UserID) string {
|
||||
s := string(userID)
|
||||
if i := strings.Index(s, ":"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
return strings.TrimPrefix(s, "@")
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -613,6 +614,22 @@ 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()
|
||||
disc := fmt.Sprintf("rival:%d", ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), 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
|
||||
}
|
||||
|
||||
101
internal/plugin/adventure_omen.go
Normal file
101
internal/plugin/adventure_omen.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/B3 — the Omen: one rotating world modifier per ISO week
|
||||
// (gogobee_engagement_plan.md §B3).
|
||||
//
|
||||
// The active omen is a pure function of the ISO (year, week): omenTable indexed
|
||||
// by (year*53 + week) % len, so it advances by exactly one entry each week and
|
||||
// needs no schema, no ticker state, and no persistence. Every seam reads
|
||||
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
|
||||
// field means "this omen doesn't touch that seam."
|
||||
//
|
||||
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
|
||||
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
|
||||
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
|
||||
// on the real clock, so a combat-affecting omen would make the combat golden
|
||||
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
|
||||
// deliberately omitted for exactly that reason.
|
||||
|
||||
type omen struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing name, e.g. "Bountiful Harvest"
|
||||
TwinBee string // first-person announce blurb (TwinBee voice)
|
||||
|
||||
// Effect fields — zero == no effect at that seam.
|
||||
HarvestYieldBonus int // + units per harvest grant
|
||||
SupplyFreebiePacks int // + complimentary standard packs at outfitting
|
||||
StartMoodBonus int // + starting DM mood on a new expedition
|
||||
ArenaPayoutMult float64 // >1 scales arena net earnings
|
||||
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
|
||||
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
|
||||
}
|
||||
|
||||
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
|
||||
// sweep's results never depend on which wall-clock week it was run in. Set true
|
||||
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
|
||||
// which returns the no-effect omen while this is set.
|
||||
var simOmenDisabled bool
|
||||
|
||||
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
|
||||
// order is the rotation order. Adding an entry reshuffles the schedule but never
|
||||
// breaks determinism (still a pure function of the week).
|
||||
var omenTable = []omen{
|
||||
{
|
||||
Key: "bountiful_harvest", Name: "Bountiful Harvest",
|
||||
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
|
||||
HarvestYieldBonus: 1,
|
||||
},
|
||||
{
|
||||
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
|
||||
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
{
|
||||
Key: "golden_purse", Name: "Golden Purse",
|
||||
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
{
|
||||
Key: "overflowing_satchels", Name: "Overflowing Satchels",
|
||||
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
{
|
||||
Key: "still_waters", Name: "Still Waters",
|
||||
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
|
||||
func omenForWeek(year, week int) omen {
|
||||
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
|
||||
return omenTable[idx%len(omenTable)]
|
||||
}
|
||||
|
||||
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
|
||||
// the balance sim has disabled it.
|
||||
func activeOmen() omen {
|
||||
if simOmenDisabled {
|
||||
return omen{Key: "none", Name: "None"}
|
||||
}
|
||||
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
|
||||
// behind the simOmenDisabled guard above so the balance sim never sees it.
|
||||
if s, ok := activeSeason(); ok {
|
||||
return s.Omen
|
||||
}
|
||||
y, w := time.Now().UTC().ISOWeek()
|
||||
return omenForWeek(y, w)
|
||||
}
|
||||
|
||||
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
|
||||
// announce seam). Empty is never returned — an omen is always active — but the
|
||||
// caller may still choose when to show it.
|
||||
func omenMorningLine(o omen) string {
|
||||
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
|
||||
}
|
||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
|
||||
func TestOmenForWeek_Deterministic(t *testing.T) {
|
||||
a := omenForWeek(2026, 28)
|
||||
b := omenForWeek(2026, 28)
|
||||
if a.Key != b.Key {
|
||||
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
|
||||
// entry, so the schedule rotates rather than sticking or skipping.
|
||||
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
|
||||
n := len(omenTable)
|
||||
for w := 1; w <= n; w++ {
|
||||
cur := omenForWeek(2026, w)
|
||||
next := omenForWeek(2026, w+1)
|
||||
wantNext := omenTable[((2026*53)+w+1)%n]
|
||||
if next.Key != wantNext.Key {
|
||||
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
|
||||
}
|
||||
if cur.Key == next.Key {
|
||||
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
|
||||
// including ISO week 53.
|
||||
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
|
||||
for y := 2020; y <= 2030; y++ {
|
||||
for w := 1; w <= 53; w++ {
|
||||
o := omenForWeek(y, w)
|
||||
if o.Key == "" {
|
||||
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
|
||||
// struct has no combat lever, so the launch set can never move the golden or the
|
||||
// balance corpus. §B3.
|
||||
func TestOmenTable_NonCombatOnly(t *testing.T) {
|
||||
for _, o := range omenTable {
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("omen %q has no active effect", o.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("omen %q missing player-facing copy", o.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
|
||||
// results are wall-clock-independent. §B3.
|
||||
func TestActiveOmen_SimDisabled(t *testing.T) {
|
||||
defer func() { simOmenDisabled = false }()
|
||||
simOmenDisabled = true
|
||||
o := activeOmen()
|
||||
if o.Key != "none" {
|
||||
t.Errorf("sim-disabled omen = %q, want none", o.Key)
|
||||
}
|
||||
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
|
||||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
|
||||
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
|
||||
// the same omen two of every len(omenTable) weeks).
|
||||
func TestOmenKeysUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range omenTable {
|
||||
if seen[o.Key] {
|
||||
t.Errorf("duplicate omen key %q", o.Key)
|
||||
}
|
||||
seen[o.Key] = true
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
|
||||
// existing morning DM (no net-new scheduled message); persistent one-liner so
|
||||
// a mid-week arrival still learns the active omen.
|
||||
sb.WriteString(omenMorningLine(activeOmen()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
|
||||
// line (its themed omen already rode the line above). Same morning DM, no
|
||||
// net-new scheduled message.
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(seasonBannerLine(s))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
|
||||
//
|
||||
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
|
||||
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
|
||||
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
|
||||
//
|
||||
// Storage is a single cumulative column, player_meta.renown_xp, written by an
|
||||
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
|
||||
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
|
||||
// stored, so nothing can disagree with the XP total.
|
||||
//
|
||||
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
|
||||
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
|
||||
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
|
||||
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
|
||||
// touches only the AdvBonusSummary activity levers and only at the activity
|
||||
// call sites, never loadCombatBonuses.
|
||||
|
||||
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
|
||||
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
|
||||
// Renown level is dozens of endgame clears.
|
||||
const renownXPPerLevel = 25000
|
||||
|
||||
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
|
||||
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
|
||||
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
|
||||
// power.
|
||||
//
|
||||
// The perks are deliberately ONLY the two combat-neutral levers of the
|
||||
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
|
||||
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
|
||||
// stats and are off-limits (§B2: never combat-stat inflation, the balance
|
||||
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
|
||||
// loot/XP economy, never by combat stat derivation — so renown can pay them out
|
||||
// even through loadCombatBonuses without moving the golden. That is why §B2's
|
||||
// suggested "−death penalty" perk is intentionally NOT granted: it would inflate
|
||||
// Defense.
|
||||
const (
|
||||
renownPerkStepLevels = 3
|
||||
renownPerkMaxSteps = 10
|
||||
)
|
||||
|
||||
// renownLevelFor derives the Renown level from cumulative overflow XP.
|
||||
func renownLevelFor(renownXP int) int {
|
||||
if renownXP <= 0 {
|
||||
return 0
|
||||
}
|
||||
return renownXP / renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
|
||||
// display. cost is always renownXPPerLevel.
|
||||
func renownXPIntoLevel(renownXP int) (int, int) {
|
||||
if renownXP < 0 {
|
||||
renownXP = 0
|
||||
}
|
||||
return renownXP % renownXPPerLevel, renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownRank is one rung of the derived title ladder: the first Renown level at
|
||||
// which the rank applies, and its name. A rank promotion (crossing into a new
|
||||
// rung) is what gets announced in the games room; plain level-ups only DM.
|
||||
type renownRank struct {
|
||||
MinLevel int
|
||||
Name string
|
||||
}
|
||||
|
||||
var renownRanks = []renownRank{
|
||||
{1, "Renowned"},
|
||||
{3, "Storied"},
|
||||
{5, "Illustrious"},
|
||||
{10, "Fabled"},
|
||||
{15, "Mythic"},
|
||||
{20, "Ascendant"},
|
||||
{30, "Eternal"},
|
||||
}
|
||||
|
||||
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
|
||||
func renownRankFor(level int) string {
|
||||
name := ""
|
||||
for _, r := range renownRanks {
|
||||
if level >= r.MinLevel {
|
||||
name = r.Name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
|
||||
// and leaderboard. Empty below Renown 1.
|
||||
func renownMarker(level int) string {
|
||||
if level < 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("✦%d", level)
|
||||
}
|
||||
|
||||
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
|
||||
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
|
||||
// — so it is safe to call anywhere the summary is built, including
|
||||
// loadCombatBonuses, without changing any combat stat (see the const block).
|
||||
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
|
||||
if b == nil || renownLevel < renownPerkStepLevels {
|
||||
return
|
||||
}
|
||||
steps := renownLevel / renownPerkStepLevels
|
||||
if steps > renownPerkMaxSteps {
|
||||
steps = renownPerkMaxSteps
|
||||
}
|
||||
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
|
||||
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
|
||||
// journal_pages pattern), returning the post-update total. Shared by the
|
||||
// standalone and transactional accrual paths so the SQL lives in one place.
|
||||
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
|
||||
RETURNING renown_xp`
|
||||
|
||||
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
|
||||
// the accrual can run standalone or inside grantDnDXP's save transaction.
|
||||
type sqlQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
|
||||
// the cumulative totals before and after. A single statement, safe against
|
||||
// concurrent grants — no lost update.
|
||||
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
|
||||
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
|
||||
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
|
||||
}
|
||||
return after - delta, after, nil
|
||||
}
|
||||
|
||||
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
|
||||
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
|
||||
// instead, so this is the API for callers not already saving a character.
|
||||
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
|
||||
if delta <= 0 {
|
||||
cur, e := loadRenownXP(userID)
|
||||
return cur, cur, e
|
||||
}
|
||||
return accrueRenownXP(db.Get(), userID, delta)
|
||||
}
|
||||
|
||||
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
|
||||
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
|
||||
// nor double-credit it. Returns the cumulative renown totals before/after.
|
||||
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = saveDnDCharacterExec(tx, c); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
|
||||
func loadRenownXP(userID id.UserID) (int, error) {
|
||||
var xp int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return xp, err
|
||||
}
|
||||
|
||||
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
|
||||
// that don't already hold the overlay. Errors resolve to 0.
|
||||
func renownLevelForUser(userID id.UserID) int {
|
||||
xp, err := loadRenownXP(userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(xp)
|
||||
}
|
||||
|
||||
// ── Announce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// announceRenown notifies the player of Renown level-ups (DM) and, when the
|
||||
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
|
||||
// not a scheduled recap. from/to are Renown levels before/after the grant.
|
||||
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
|
||||
if p == nil || to <= from {
|
||||
return
|
||||
}
|
||||
gained := to - from
|
||||
rank := renownRankFor(to)
|
||||
if p.Client != nil {
|
||||
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
|
||||
to, pluralLevels(gained))
|
||||
if rank != "" {
|
||||
msg += fmt.Sprintf("\nRank: **%s**.", rank)
|
||||
}
|
||||
if err := p.SendDM(userID, msg); err != nil {
|
||||
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
// Games-room shout only on a rank promotion, to keep the room quiet.
|
||||
if renownRankFor(from) == rank {
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
name, _ := loadDisplayName(userID)
|
||||
if name == "" {
|
||||
name = string(userID)
|
||||
}
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
|
||||
}
|
||||
|
||||
func pluralLevels(n int) string {
|
||||
if n == 1 {
|
||||
return "a Renown level"
|
||||
}
|
||||
return fmt.Sprintf("%d Renown levels", n)
|
||||
}
|
||||
238
internal/plugin/adventure_renown_test.go
Normal file
238
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
|
||||
func TestRenownAtLeast(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_ach:example")
|
||||
if err := createAdvCharacter(uid, "Achiever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := db.Get()
|
||||
if renownAtLeast(d, uid, 1) {
|
||||
t.Errorf("no renown yet, should not meet level 1")
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !renownAtLeast(d, uid, 5) {
|
||||
t.Errorf("renown 5 should meet level 5")
|
||||
}
|
||||
if renownAtLeast(d, uid, 6) {
|
||||
t.Errorf("renown 5 should not meet level 6")
|
||||
}
|
||||
// Absent player → false, not a crash.
|
||||
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
|
||||
t.Errorf("absent player should not meet any renown level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
|
||||
199
internal/plugin/adventure_season.go
Normal file
199
internal/plugin/adventure_season.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
|
||||
//
|
||||
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
|
||||
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
|
||||
// rotation), a season spans a window around its anchor date and layers three
|
||||
// things on top of the world for that week:
|
||||
//
|
||||
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
|
||||
// rotation (see activeOmen). It reuses the omen effect fields, so the same
|
||||
// non-combat rule holds: a season never touches SimulateCombat or the turn
|
||||
// engine. That is enforced structurally — activeSeason() honours
|
||||
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
|
||||
// sees a season through any path, and the season Omen only reaches the sim's
|
||||
// omen seams behind activeOmen's own simOmenDisabled guard.
|
||||
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
|
||||
// registry items (dailyCuriosStock). No new power enters the game; the shelf
|
||||
// just changes which items rotate in for the week.
|
||||
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
|
||||
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
|
||||
// ambient seam has never opened a fight and this does not change that.
|
||||
//
|
||||
// A season is a pure function of the UTC date and the anchor calendar, so it
|
||||
// needs no schema, no ticker state, and no persistence — the same discipline as
|
||||
// the Omen and the holiday calendar.
|
||||
|
||||
// seasonHalfWidth is the number of days on each side of the anchor date that the
|
||||
// season is live. 3 → a 7-day window (anchor ± 3).
|
||||
const seasonHalfWidth = 3
|
||||
|
||||
// seasonVisitor is the themed ambient event a season adds to the road. It never
|
||||
// resolves combat — it leaves a sellable keepsake and a small coin gift.
|
||||
type seasonVisitor struct {
|
||||
Weight int // relative weight in the ambient pick
|
||||
FlavorPool []string // scene narration lines for the visit
|
||||
Keepsake string // sellable trophy left behind
|
||||
KeepsakeValue int64 // coin baseline of the keepsake
|
||||
Coins int // flat coin gift
|
||||
}
|
||||
|
||||
// season is one holiday skin.
|
||||
type season struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing, e.g. "Hallowtide"
|
||||
Emoji string // banner emoji
|
||||
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
|
||||
Blurb string // one-line "what's live this week" banner copy
|
||||
Omen omen // themed override omen (non-combat fields only)
|
||||
CurioIDs []string // curated registry IDs for Luigi's shelf
|
||||
Visitor seasonVisitor // the road visitor
|
||||
}
|
||||
|
||||
// fixedAnchor builds an Anchor for a fixed month/day holiday.
|
||||
func fixedAnchor(month time.Month, day int) func(int) time.Time {
|
||||
return func(year int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// seasonTable is the set of anchor holidays that get a skin. Order is match
|
||||
// order; the windows are disjoint so it never matters, but keep them disjoint.
|
||||
var seasonTable = []season{
|
||||
{
|
||||
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
|
||||
Anchor: fixedAnchor(time.October, 31),
|
||||
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
|
||||
Omen: omen{
|
||||
Key: "hallowtide", Name: "Hallowtide",
|
||||
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
|
||||
"slippers_of_spider_climbing", "staff_of_swarming_insects",
|
||||
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
|
||||
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
|
||||
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
|
||||
},
|
||||
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
|
||||
Anchor: fixedAnchor(time.December, 25),
|
||||
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "midwinter", Name: "Midwinter Feast",
|
||||
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
|
||||
"staff_of_frost", "potion_of_healing", "staff_of_healing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
|
||||
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
|
||||
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
|
||||
},
|
||||
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
|
||||
Anchor: fixedAnchor(time.February, 14),
|
||||
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel",
|
||||
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"eyes_of_charming", "philter_of_love", "staff_of_charming",
|
||||
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
|
||||
"glamoured_studded_leather",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
|
||||
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
|
||||
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
|
||||
},
|
||||
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
|
||||
Anchor: func(year int) time.Time { return easterDate(year) },
|
||||
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "first_bloom", Name: "First Bloom",
|
||||
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
|
||||
HarvestYieldBonus: 1,
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
|
||||
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
|
||||
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
|
||||
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
|
||||
},
|
||||
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// activeSeason returns the season whose window contains today (UTC), or false.
|
||||
// Honours simOmenDisabled so the balance sim never observes a season through any
|
||||
// path — the same neutralisation activeOmen applies to the weekly rotation.
|
||||
func activeSeason() (season, bool) {
|
||||
if simOmenDisabled {
|
||||
return season{}, false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return seasonForDate(today)
|
||||
}
|
||||
|
||||
// seasonForDate is the pure core of activeSeason, split out for tests. The
|
||||
// anchor is checked against the target year and its neighbours so a window that
|
||||
// straddles a year boundary still resolves.
|
||||
func seasonForDate(today time.Time) (season, bool) {
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
lo := a.AddDate(0, 0, -seasonHalfWidth)
|
||||
hi := a.AddDate(0, 0, seasonHalfWidth)
|
||||
if !today.Before(lo) && !today.After(hi) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return season{}, false
|
||||
}
|
||||
|
||||
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
|
||||
// Omen line in the morning DM. Empty when no season is active.
|
||||
func seasonBannerLine(s season) string {
|
||||
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
|
||||
}
|
||||
120
internal/plugin/adventure_season_test.go
Normal file
120
internal/plugin/adventure_season_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
|
||||
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
|
||||
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
a := s.Anchor(2026)
|
||||
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
|
||||
got, ok := seasonForDate(a.AddDate(0, 0, off))
|
||||
if !ok || got.Key != s.Key {
|
||||
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
|
||||
s.Key, off, ok, got.Key, s.Key)
|
||||
}
|
||||
}
|
||||
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
|
||||
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
|
||||
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
|
||||
// the first-match order in seasonForDate never hides overlapping content.
|
||||
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
|
||||
for y := 2024; y <= 2030; y++ {
|
||||
day := date(y, time.January, 1)
|
||||
end := date(y+1, time.January, 1)
|
||||
for day.Before(end) {
|
||||
matches := 0
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
|
||||
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
|
||||
matches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
|
||||
day.Format("2006-01-02"), matches)
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
|
||||
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
|
||||
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
|
||||
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
if len(s.CurioIDs) == 0 {
|
||||
t.Errorf("%s has no curated curios", s.Key)
|
||||
}
|
||||
for _, id := range s.CurioIDs {
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
|
||||
// only touches the non-combat omen levers, so overriding the weekly rotation with
|
||||
// it can never move the combat golden or the balance corpus. §E4/§B3.
|
||||
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
o := s.Omen
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("%s omen has no active effect", s.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("%s omen missing player-facing copy", s.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
|
||||
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
|
||||
// has a real reward to grant.
|
||||
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
v := s.Visitor
|
||||
if len(v.FlavorPool) == 0 {
|
||||
t.Errorf("%s visitor has no flavor", s.Key)
|
||||
}
|
||||
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
|
||||
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
|
||||
}
|
||||
if v.Weight <= 0 {
|
||||
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
|
||||
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
|
||||
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
|
||||
prev := simOmenDisabled
|
||||
simOmenDisabled = true
|
||||
defer func() { simOmenDisabled = prev }()
|
||||
if s, ok := activeSeason(); ok {
|
||||
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
|
||||
}
|
||||
}
|
||||
@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock := make([]MagicItem, 0, target)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// N7/E4 — a live season features its curated items at the front of the shelf,
|
||||
// displacing the day's rotation. They are existing registry items, so no
|
||||
// net-new power enters the economy for the week; only which items rotate in.
|
||||
if s, ok := activeSeason(); ok {
|
||||
for _, id := range s.CurioIDs {
|
||||
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
|
||||
stock = append(stock, mi)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if len(stock) > target {
|
||||
target = len(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the remaining slots from the day's deterministic rotation.
|
||||
for _, id := range ids {
|
||||
if len(stock) >= target {
|
||||
break
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
|
||||
} else {
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
227
internal/plugin/bootstrap_pete_news.go
Normal file
227
internal/plugin/bootstrap_pete_news.go
Normal file
@@ -0,0 +1,227 @@
|
||||
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 := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), 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 := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), 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("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||
// which combat stat derivation never reads (see adventure_renown.go).
|
||||
applyRenownBonuses(b, char.RenownLevel())
|
||||
return b
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
|
||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||
// so the upsert can run either standalone or inside a caller's transaction.
|
||||
type sqlExecer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// SaveDnDCharacter upserts the row.
|
||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
return saveDnDCharacterExec(db.Get(), c)
|
||||
}
|
||||
|
||||
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||
// transaction (adventure_renown.go), so the character save and the renown
|
||||
// credit commit atomically.
|
||||
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
pending := 0
|
||||
if c.PendingSetup {
|
||||
pending = 1
|
||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
_, err := ex.Exec(`
|
||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||
hp_current, hp_max, temp_hp, armor_class,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
|
||||
c := autoBuildCharacter(userID, char)
|
||||
c.AutoMigrated = true
|
||||
c.PendingSetup = false
|
||||
// Seed the canonical player_meta row before handing back a confirmed
|
||||
// character. Auto-migration otherwise mints a player_meta-less character
|
||||
// (the camcast straggler) that fails every legacy-layer command with
|
||||
// "sql: no rows". No-op for legacy players who already have the row.
|
||||
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||||
return nil, false, fmt.Errorf("seed player_meta: %w", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -567,4 +576,32 @@ 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) {
|
||||
// Gate before the char lookups: markAdventureDead fires per-member on a
|
||||
// party wipe and in the sim harness, so skip the DB reads when disabled.
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
lvl := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||
EventType: "death",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||
startMood += b
|
||||
}
|
||||
exp := &Expedition{
|
||||
ID: newExpeditionID(),
|
||||
UserID: string(userID),
|
||||
|
||||
@@ -384,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||
suppliesPurchase.StandardPacks += pk
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
qty++
|
||||
}
|
||||
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||
qty += b
|
||||
}
|
||||
tier := zoneTierFromID(res.ZoneID)
|
||||
for i := 0; i < qty; i++ {
|
||||
item := AdvItem{
|
||||
|
||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
return 0, "", nil
|
||||
}
|
||||
delta, reason := dailyThreatDrift(e.DMMood)
|
||||
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
|
||||
// *rise* only (guarded on delta > 0), floored so the worst case is threat
|
||||
// holding steady for the day; it never turns a rise into active decay.
|
||||
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
|
||||
if delta -= r; delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
}
|
||||
if delta == 0 {
|
||||
return 0, reason, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||
if c.Level >= dndMaxLevel {
|
||||
b.WriteString("XP: capped at L20.")
|
||||
if rl := advChar.RenownLevel(); rl > 0 {
|
||||
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||
if rank := renownRankFor(rl); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||
} else {
|
||||
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||
}
|
||||
} else {
|
||||
next := dndXPToNextLevel(c.Level)
|
||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||
|
||||
@@ -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:" + eventToken(ctx.Sender, "arrival"),
|
||||
EventType: "arrival",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
ClassRace: classRaceLabel(c),
|
||||
Level: c.Level,
|
||||
OccurredAt: ts,
|
||||
}, ctx.Sender, "")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
renownLevel := 0
|
||||
if adv != nil {
|
||||
renownLevel = adv.RenownLevel()
|
||||
}
|
||||
nameLine := name
|
||||
if m := renownMarker(renownLevel); m != "" {
|
||||
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||
if renownLevel > 0 {
|
||||
if rank := renownRankFor(renownLevel); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||
}
|
||||
}
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
|
||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
||||
// be dropped converts to Renown in the SAME transaction as the save, so a
|
||||
// crash can neither lose the overflow nor double-credit it on the next grant.
|
||||
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
||||
// the balance corpus stays valid.
|
||||
var renownFrom, renownTo int
|
||||
if c.Level >= dndMaxLevel && c.XP > 0 {
|
||||
overflow := c.XP
|
||||
c.XP = 0
|
||||
}
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
||||
} else if err := SaveDnDCharacter(c); err != nil {
|
||||
return events, err
|
||||
}
|
||||
|
||||
p.announceRenown(userID, renownFrom, renownTo)
|
||||
|
||||
if len(events) > 0 {
|
||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
||||
|
||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||
if rand.Float64() >= advIngredientDropChance {
|
||||
chance := advIngredientDropChance
|
||||
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||
chance *= m
|
||||
}
|
||||
if rand.Float64() >= chance {
|
||||
return nil
|
||||
}
|
||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||
|
||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||
// before opening it, so even the copy is left pristine.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||
//
|
||||
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||
// error in one is reported and the battery continues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||
if srcDir == "" {
|
||||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||
t.Skipf("no gogobee.db in %s", srcDir)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(tmp); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||
// so the same DB copy exercises identically regardless of which week we run.
|
||||
simOmenDisabled = true
|
||||
simAutoArmEnabled = true
|
||||
|
||||
euro := NewEuroPlugin(nil)
|
||||
xp := NewXPPlugin(nil)
|
||||
p := NewAdventurePlugin(nil, euro, xp)
|
||||
sink := &captureSink{}
|
||||
p.Sink = sink
|
||||
mark := 0
|
||||
drain := func() {
|
||||
for _, m := range sink.msgs[mark:] {
|
||||
who := string(m.ToUser)
|
||||
if who == "" {
|
||||
who = "room:" + string(m.ToRoom)
|
||||
}
|
||||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||
}
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
exercise := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
}()
|
||||
t.Logf("──────── %s ────────", name)
|
||||
fn()
|
||||
drain()
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) == 0 {
|
||||
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||
}
|
||||
t.Logf("runnable characters: %d", len(chars))
|
||||
for _, c := range chars {
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||
}
|
||||
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||
}
|
||||
|
||||
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||
for _, c := range chars {
|
||||
xp, _ := loadRenownXP(c.uid)
|
||||
lvl := renownLevelForUser(c.uid)
|
||||
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||
}
|
||||
// Push the first character up a renown level to fire the announce path.
|
||||
c0 := chars[0]
|
||||
before, after, err := addRenownXP(c0.uid, 500)
|
||||
if err != nil {
|
||||
t.Logf(" addRenownXP: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||
})
|
||||
|
||||
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||
if err != nil {
|
||||
t.Logf(" spawnWorldBoss: %v", err)
|
||||
}
|
||||
if boss != nil {
|
||||
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||
}
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
for _, c := range chars {
|
||||
t.Logf(" -- %s takes a bout --", c.uid)
|
||||
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||
}
|
||||
t.Logf(" -- pool after the raid --")
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
})
|
||||
|
||||
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters for a duel; skipping")
|
||||
return
|
||||
}
|
||||
a, b := chars[0], chars[1]
|
||||
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||
})
|
||||
|
||||
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||
for _, c := range chars {
|
||||
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||
for _, c := range chars {
|
||||
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||
})
|
||||
|
||||
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||
for _, c := range chars {
|
||||
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters to gift; skipping")
|
||||
return
|
||||
}
|
||||
giver, receiver := chars[0], chars[1]
|
||||
item := firstInventoryItem(giver.uid)
|
||||
if item == "" {
|
||||
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||
})
|
||||
|
||||
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||
exercise("N7/B4 Achievements — !achievements", func() {
|
||||
ach := NewAchievementsPlugin(nil, nil)
|
||||
ach.Sink = sink
|
||||
p.SetAchievements(ach)
|
||||
for _, c := range chars {
|
||||
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
type exChar struct {
|
||||
uid id.UserID
|
||||
class string
|
||||
level int
|
||||
}
|
||||
|
||||
// runnableChars lists characters that have both a completed dnd_character (class
|
||||
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||
func runnableChars(t *testing.T) []exChar {
|
||||
t.Helper()
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT d.user_id, d.class, d.dnd_level
|
||||
FROM dnd_character d
|
||||
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||
WHERE d.class != ''`)
|
||||
if err != nil {
|
||||
t.Fatalf("query chars: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []exChar
|
||||
for rows.Next() {
|
||||
var c exChar
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.uid = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||
return out
|
||||
}
|
||||
|
||||
func healToFull(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||
return
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInventoryItem(uid id.UserID) string {
|
||||
d := db.Get()
|
||||
var name string
|
||||
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||
_ = d.QueryRow(
|
||||
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||
string(uid),
|
||||
).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func copyIfPresent(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return // wal/shm may be absent — fine
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = " " + l
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
|
||||
// it's cheap to test in isolation and free of package-level state.
|
||||
func ambientEvents() []ambientEvent {
|
||||
always := func(*Expedition) bool { return true }
|
||||
return []ambientEvent{
|
||||
events := []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
|
||||
},
|
||||
},
|
||||
}
|
||||
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
|
||||
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
|
||||
// reward come from activeSeason(); the ambient seam is production-only, so
|
||||
// this never reaches the balance sim.
|
||||
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
|
||||
events = append(events, ambientEvent{
|
||||
Kind: "season_visitor",
|
||||
Pool: s.Visitor.FlavorPool,
|
||||
Weight: s.Visitor.Weight,
|
||||
Eligible: func(*Expedition) bool { return true },
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return ""
|
||||
}
|
||||
return "+1 HP"
|
||||
case "season_visitor":
|
||||
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
|
||||
// the season so the reward matches the current window even if it turned
|
||||
// over between pick and apply; bail quietly if it just ended.
|
||||
s, ok := activeSeason()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v := s.Visitor
|
||||
uid := id.UserID(e.UserID)
|
||||
if err := addAdvInventoryItem(uid, AdvItem{
|
||||
Name: v.Keepsake,
|
||||
Type: "trophy",
|
||||
Tier: 1,
|
||||
Value: v.KeepsakeValue,
|
||||
}); err != nil {
|
||||
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
|
||||
return ""
|
||||
}
|
||||
if v.Coins > 0 {
|
||||
if p.euro != nil {
|
||||
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET coins_earned = coins_earned + ?,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
|
||||
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s.", v.Keepsake)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
||||
// etc. fire the way a competent prod player would set them up.
|
||||
// Without this the sim under-counts class survival.
|
||||
simAutoArmEnabled = true
|
||||
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
|
||||
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
|
||||
// activeOmen's time.Now()).
|
||||
simOmenDisabled = true
|
||||
euro := &EuroPlugin{}
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
return &SimRunner{P: p, Euro: euro}, nil
|
||||
@@ -127,6 +131,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||
// affords itself regardless of the player's live coin balance. Returns the
|
||||
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||
}
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||
}
|
||||
if c.Class == "" {
|
||||
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||
}
|
||||
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||
// + a couple offensive items into the synthetic player's inventory so
|
||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||
|
||||
303
internal/plugin/pete.go
Normal file
303
internal/plugin/pete.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
newsGUIDSaltKey = "adventure_news_guid_salt"
|
||||
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. Persisted in the
|
||||
// durable news_config table (NOT api_cache, which RunMaintenance prunes).
|
||||
func newsEmissionOn() bool {
|
||||
var v string
|
||||
err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v)
|
||||
if err != nil {
|
||||
return true // no row → default on
|
||||
}
|
||||
return v != "0"
|
||||
}
|
||||
|
||||
// setNewsEmission persists the runtime kill-switch.
|
||||
func setNewsEmission(on bool) {
|
||||
v := "1"
|
||||
if !on {
|
||||
v = "0"
|
||||
}
|
||||
db.Exec("news emission set",
|
||||
`INSERT INTO news_config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
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))
|
||||
}
|
||||
|
||||
var (
|
||||
guidSaltOnce sync.Once
|
||||
guidSalt []byte
|
||||
)
|
||||
|
||||
// newsGUIDSalt returns the secret, server-side salt that keys every public event
|
||||
// token. It is 32 random bytes, generated once and persisted in the durable
|
||||
// news_config table, cached in-process. Secret from anyone outside the server
|
||||
// (external users never see it), stable across restarts (so a re-emit or the
|
||||
// cold-start backfill reproduces the same GUID). No operator action needed.
|
||||
func newsGUIDSalt() []byte {
|
||||
guidSaltOnce.Do(func() {
|
||||
var stored string
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil {
|
||||
if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 {
|
||||
guidSalt = b
|
||||
return
|
||||
}
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand should never fail; if it does, refuse to fall back to a
|
||||
// predictable salt (that would reopen the enumeration attack). Leave the
|
||||
// salt empty and let eventToken sort it out.
|
||||
return
|
||||
}
|
||||
salt := hex.EncodeToString(b)
|
||||
// Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then
|
||||
// re-read the row so every caller agrees on the same salt.
|
||||
db.Exec("news guid salt seed",
|
||||
`INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt)
|
||||
_ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt)
|
||||
guidSalt, _ = hex.DecodeString(salt)
|
||||
})
|
||||
return guidSalt
|
||||
}
|
||||
|
||||
// eventToken is the per-event, one-way identifier embedded in a public GUID
|
||||
// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the
|
||||
// Matrix user id AND an event-specific discriminator, keyed by the secret
|
||||
// newsGUIDSalt.
|
||||
//
|
||||
// Two properties matter and both come from HMAC being a PRF under a secret key:
|
||||
// - Not computable from a Matrix handle. An attacker who knows @alice:server
|
||||
// cannot derive any of her tokens without the salt — so her events (including
|
||||
// ones anonymized after `!news optout`) can't be enumerated from her handle.
|
||||
// - Per-event, so tokens are mutually unlinkable. Even for the same user, two
|
||||
// events yield unrelated tokens; a story that names her (opted-in, or a duel
|
||||
// opponent) exposes only that one event's token and can't be walked back to
|
||||
// her other, anonymized events.
|
||||
//
|
||||
// The discriminator must uniquely identify the logical event so a re-emit or the
|
||||
// backfill reproduces the same token (idempotency rides on the GUID).
|
||||
func eventToken(userID id.UserID, discriminator string) string {
|
||||
mac := hmac.New(sha256.New, newsGUIDSalt())
|
||||
mac.Write([]byte(userID))
|
||||
mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding
|
||||
mac.Write([]byte(discriminator))
|
||||
return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// charLevel returns a player's current character level for a news Fact, or 0 if
|
||||
// no character loads (Level is omitempty, so 0 is dropped from the payload).
|
||||
func charLevel(userID id.UserID) int {
|
||||
if dc, _ := LoadDnDCharacter(userID); dc != nil {
|
||||
return dc.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 a PRIORITY "zone_first"; later clears are a
|
||||
// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so
|
||||
// Pete templates and labels a repeat as a repeat, not a first-ever. Character
|
||||
// name only; no-op unless the seam is enabled.
|
||||
func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||
// genuine first clear still seeds news_realm_firsts. Otherwise the next
|
||||
// named clearer would claim it and be mis-announced as the first-ever.
|
||||
// Mirrors backfillZoneFirsts, which claims before its own name check.
|
||||
eventType, tier := "zone_clear", "bulletin"
|
||||
if claimRealmFirst("zone", string(exp.ZoneID)) {
|
||||
eventType, tier = "zone_first", "priority"
|
||||
}
|
||||
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 := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
|
||||
EventType: eventType,
|
||||
Tier: tier,
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Region: region,
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
216
internal/plugin/pete_test.go
Normal file
216
internal/plugin/pete_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"sync"
|
||||
"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, "milestone:%"); got != 1 {
|
||||
t.Errorf("achievement dispatches queued = %d, want 1", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_first:%"); 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
|
||||
// can recompute from the handle), stable per logical event (idempotency), and
|
||||
// per-event so a user's tokens don't link to each other.
|
||||
func TestEventToken(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)
|
||||
|
||||
// The salt is cached process-wide via sync.Once; reset it so it derives (and
|
||||
// persists) against this test's fresh DB rather than an earlier test's.
|
||||
guidSaltOnce = sync.Once{}
|
||||
guidSalt = nil
|
||||
|
||||
u := id.UserID("@alice:example.org")
|
||||
|
||||
// Not the old unsalted digest: an attacker with the handle can't recompute it.
|
||||
bare := sha256.Sum256([]byte(u))
|
||||
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
|
||||
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
|
||||
}
|
||||
|
||||
// Stable for the same (user, discriminator): a re-emit dedups.
|
||||
if eventToken(u, "arrival") != eventToken(u, "arrival") {
|
||||
t.Error("token not stable for the same event — idempotency broken")
|
||||
}
|
||||
|
||||
// Per-event: two different events for the same user yield unrelated tokens, so
|
||||
// a named event can't be walked back to the user's anonymized events.
|
||||
if eventToken(u, "arrival") == eventToken(u, "death:123") {
|
||||
t.Error("tokens collide across events — linkage attack reopened")
|
||||
}
|
||||
|
||||
// Different users, same discriminator, still differ.
|
||||
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
|
||||
t.Error("token collides across users")
|
||||
}
|
||||
|
||||
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
|
||||
var salt string
|
||||
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
|
||||
t.Fatalf("guid salt not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
|
||||
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
|
||||
// GUID prefix, so Pete never files a repeat as a first-ever.
|
||||
func TestEmitZoneClearTaxonomy(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)
|
||||
|
||||
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
|
||||
"@zapp:x", "Zapp")
|
||||
exp := &Expedition{ZoneID: "goblin_warren"}
|
||||
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
|
||||
|
||||
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_clear:%"); got != 1 {
|
||||
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", 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")
|
||||
}
|
||||
}
|
||||
@@ -1349,6 +1349,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
||||
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
||||
c.EpilogueCleared = cleared
|
||||
}
|
||||
if xp, err := loadRenownXP(uid); err == nil {
|
||||
c.RenownXP = xp
|
||||
}
|
||||
if s, err := loadHouseState(uid); err == nil {
|
||||
c.HouseTier = s.Tier
|
||||
c.HouseLoanBalance = s.LoanBalance
|
||||
|
||||
81
internal/plugin/player_meta_seed_test.go
Normal file
81
internal/plugin/player_meta_seed_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestAutoMigrationSeedsPlayerMeta guards the camcast straggler regression: a
|
||||
// brand-new player whose first-ever adventure action auto-migrates a character
|
||||
// (e.g. !rest before !setup) must come out with a canonical player_meta seed,
|
||||
// or every legacy-layer command (expeditions, arena, world boss, town…) fails
|
||||
// with "sql: no rows". Before the ensurePlayerMetaSeed guard this produced a
|
||||
// confirmed dnd_character with zero player_meta rows.
|
||||
func TestAutoMigrationSeedsPlayerMeta(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@fresh-automigrant:example.org")
|
||||
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
p.Sink = &captureSink{}
|
||||
|
||||
// First-ever adventure action for a player with no character at all.
|
||||
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("handleDnDShortRest: %v", err)
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var dndRows, pmRows, equipRows, pending int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&dndRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||
d.QueryRow(`SELECT COALESCE(MAX(pending_setup),-1) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&pending)
|
||||
|
||||
if dndRows != 1 {
|
||||
t.Fatalf("expected 1 auto-migrated dnd_character, got %d", dndRows)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Errorf("auto-migrated character should be confirmed (pending_setup=0), got %d", pending)
|
||||
}
|
||||
if pmRows != 1 {
|
||||
t.Errorf("player_meta not seeded for auto-migrant: got %d rows, want 1 (camcast straggler regression)", pmRows)
|
||||
}
|
||||
// createAdvCharacter seeds tier-0 gear in all five slots.
|
||||
if equipRows != len(allSlots) {
|
||||
t.Errorf("tier-0 equipment not seeded: got %d rows, want %d", equipRows, len(allSlots))
|
||||
}
|
||||
|
||||
// The character must now load through the legacy layer that camcast choked on.
|
||||
if _, err := loadAdvCharacter(uid); err != nil {
|
||||
t.Errorf("loadAdvCharacter after auto-migration: %v (this is the exact failure camcast hit)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsurePlayerMetaSeedIdempotent proves the guard never duplicates a legacy
|
||||
// player's equipment — createAdvCharacter's tier-0 insert has no conflict guard,
|
||||
// so the seed must be conditional on player_meta being absent.
|
||||
func TestEnsurePlayerMetaSeedIdempotent(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@legacy:example.org")
|
||||
|
||||
// Seed once (fresh), then again (should be a no-op).
|
||||
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var pmRows, equipRows int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||
if pmRows != 1 {
|
||||
t.Errorf("player_meta rows = %d, want 1 (no duplication)", pmRows)
|
||||
}
|
||||
if equipRows != len(allSlots) {
|
||||
t.Errorf("equipment rows = %d, want %d (guard must not re-insert gear)", equipRows, len(allSlots))
|
||||
}
|
||||
}
|
||||
@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||
|
||||
// SendReply sends a reply to a specific event.
|
||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||
// The sink is the headless capture seam: a reply is an outbound room
|
||||
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
||||
// the live client is never touched. Without this branch, reply-only handlers
|
||||
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
||||
if b != nil && b.Sink != nil {
|
||||
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||
return err
|
||||
}
|
||||
content := textContent(text)
|
||||
if eventID != "" {
|
||||
content.RelatesTo = &event.RelatesTo{
|
||||
|
||||
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