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
This commit is contained in:
prosolis
2026-07-10 23:32:52 -07:00
parent a6f1de4e74
commit 3f4b4ece5c
4 changed files with 372 additions and 0 deletions

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

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

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{