12 Commits

Author SHA1 Message Date
prosolis
f4a4c0d30b Merge adventure-news: Pete Adventure News seam + code-review fixes 2026-07-11 07:57:20 -07:00
prosolis
1634bb1970 Pete news code-review fixes: realm-first ordering, parked-row reap, death-emit gate
- emitZoneClearNews: claim realm-first before the name guard so an unnamed
  straggler's first clear seeds news_realm_firsts (else the next named
  clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts.
- RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent,
  >30d) so a durable Pete outage can't accrete rows forever.
- emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB
  reads; markAdventureDead fires per-member on party wipes + in the sim.
2026-07-11 07:56:41 -07:00
prosolis
ae7ff38996 Pete news: salted per-event GUID token + zone_clear taxonomy
Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.

A. Privacy — the public GUID no longer leaks a stable per-player id.
   Replaced userHash(userID)=sha256(userID)[:6] with
   eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
   The salt is 32 random bytes, auto-generated once and persisted in the
   durable news_config table (cached via sync.Once). Because each event
   uses a distinct HMAC message, tokens are a PRF output and are BOTH
   uncomputable from a Matrix handle (no enumeration of a player's
   events, incl. ones anonymized after !news optout) AND mutually
   unlinkable (a named event can't be walked back to a player's other,
   anonymized events). Updated all emit sites: pete.go zone, dnd_combat
   death, adventure_duel rival, dnd_setup arrival, achievements
   milestone, bootstrap x3.

B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
   zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
   the invariant GUID-prefix == event_type, which also fixes latent
   permalink mislabels (achv->milestone, rival->rival_result rendered a
   neutral "Dispatch" on their permalink pages).

Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.

Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.

Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.
2026-07-11 07:43:57 -07:00
prosolis
b42beec348 Pete Adventure News: emit seam, !news command, cold-start backfill
gogobee's game-side of Pete's push-based Adventure news feed. Emits
structured event *facts* (not prose) to Pete's ingest endpoint over a
durable queue; Pete owns voice/authoring/publishing.

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

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

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:25 -07:00
prosolis
0c4c4757d3 Merge fix-automigration-player-meta-seed: seed player_meta on auto-migration
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:52:14 -07:00
prosolis
fedd357a29 Seed player_meta on auto-migration to prevent player_meta-less stragglers
The !setup confirm path seeds the canonical player_meta row (commit
667f87f), but the auto-migration path (ensureDnDCharacterForCombat) writes
a confirmed dnd_character without touching the legacy layer. Commands that
trigger it — !rest, !cast, !abilities, !skills — derive the adventure char
via a bare loadAdvCharacter that is nil when player_meta is absent, and
autoBuildCharacter tolerates a nil char. So a brand-new player whose
first-ever adventure action is one of those gets a confirmed character with
no player_meta, which then fails every legacy-layer command (expeditions,
arena, world boss, town, duels) with "sql: no rows" — the same state
@camcast was found in.

Fix: ensurePlayerMetaSeed guarantees the seed row (+ tier-0 equipment)
exists at the fresh auto-migration point. Conditional on player_meta being
absent, so it's idempotent and never duplicates a legacy player's gear
(createAdvCharacter's equipment insert has no conflict guard).

Regression tests cover both the straggler repro (first-ever !rest seeds
player_meta + loads cleanly) and idempotency (no equipment duplication).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:50:48 -07:00
prosolis
59319ede9d Merge sim-real-char-harness: headless real-char sim + feature exercise harness
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:34:28 -07:00
prosolis
3f4b4ece5c Headless real-character sim + new-feature exercise harness
Run the actual Adventure module against a copy of the prod DB with no
Matrix client, to smoke-test before deploy.

- expedition-sim: -real-user @mxid runs an EXISTING character loaded from
  -data's gogobee.db instead of a synthetic build. SimRunner gains
  PrepareRealCharacter (heals to full + tops up bankroll; keeps real
  race/class/subclass/level/gear/spells).
- plugin.SendReply now honors the MessageSink like SendMessage/SendDM.
  Reply-based handlers (duels, !town, !rivals, !achievements) previously
  bypassed the capture seam and hit a nil client under the sink. Prod
  behavior is unchanged (sink is nil in production).
- exercise_prod_test.go (build tag: prodexercise) drives every N-series
  feature — world boss, duels, Shadow, Renown, achievements, journal,
  town registries, vault, gifting — against a prod DB copy with all
  outbound messages captured. Gated on GOGOBEE_PROD_DB_DIR; never runs in
  normal CI.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:32:52 -07:00
prosolis
a6f1de4e74 N7/E4: Seasonal events — 1-week holiday skins
Four anchor holidays (Hallowtide, Midwinter Feast, Sweethearts' Revel,
First Bloom) each get a 7-day window (anchor ±3 days) that layers three
things on the world, all reusing existing machinery:

  1. A themed Omen that overrides the weekly rotation. Reuses the B3 omen
     effect fields, so the non-combat rule holds; kept behind activeOmen's
     simOmenDisabled guard (and activeSeason honours it too) so no season
     path can reach the balance sim or move the golden.
  2. A curated curio shelf at Luigi's — existing registry items rotated to
     the front of dailyCuriosStock, so no net-new power enters the economy.
     Off-season output is byte-identical to before.
  3. A themed road visitor via the ambient seam — a season_visitor event
     that leaves a sellable keepsake + coin gift. No combat: the ambient
     seam still never opens a fight.

Pure function of the UTC date + anchor calendar — no schema, ticker, or
persistence, same discipline as the Omen and holiday calendar. Season
banner rides the existing morning DM (no net-new scheduled message).

go test ./internal/plugin ./internal/db green; combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:30:15 -07:00
prosolis
1a47a2fdee N7/B4: Renown achievements wing
Three passive achievements (renown_1/5/10) gated on the derived Renown level
via renownAtLeast, reading player_meta.renown_xp through the achievements
plugin's existing Check(d, userID) seam. No event hook — the passive checker
grants them on the next message once the level is reached.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
38a3693832 N7/B2: Renown — prestige past the L20 cap
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.

Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).

The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.

Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:48:49 -07:00
41 changed files with 2985 additions and 18 deletions

View File

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

View File

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

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

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

View File

@@ -9,6 +9,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
}
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
_, err := d.Exec(
res, err := d.Exec(
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
string(userID), achievementID,
)
@@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
return
}
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
if n, _ := res.RowsAffected(); n == 0 {
return
}
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
p.emitMilestoneNews(d, userID, achievementID)
}
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
// only — rarity-gated to a single holder (per the news selection thresholds) so
// routine unlocks don't flood the feed. Character name only; no-op unless the
// seam is enabled.
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
var holders int
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
return
}
name := charName(userID)
if name == "" {
return
}
label := achievementID
for _, a := range p.achievements {
if a.ID == achievementID {
label = a.Name
break
}
}
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: fmt.Sprintf("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-

View File

@@ -184,6 +184,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
}
}
@@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error {
// arrival roll. Both idempotent via JobCompleted gates.
bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet()
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
// deaths, single-holder achievements) the first boot the seam is live, so
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
p.bootstrapPeteNewsBackfill()
// Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which
@@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "rivals") {
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
}
if p.IsCommand(ctx.Body, "news") {
return p.handleNewsCmd(ctx)
}
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {
@@ -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)

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
@@ -613,6 +614,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
}

View 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 + "_"
}

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

View File

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

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

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

View File

@@ -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 := ""

View 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 + "."
}

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -177,6 +177,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
lines = append(lines, sl)
}
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
emitZoneClearNews(userID, exp)
return lines
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
// Idempotent — !setup confirm after a respec wipe will repopulate.
_ = ensureSpellsForCharacter(c)
// A new character walked through the gates — file a welcome dispatch to
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
// re-announce the same arrival. No-op unless the seam is enabled.
if name := charName(ctx.Sender); name != "" {
ts := nowUnix()
emitFact(peteclient.Fact{
GUID: "arrival:" + 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))
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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