From 3f4b4ece5cbc4ef3c9eb4ffc1f0dfdd4fa41ca45 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:32:52 -0700 Subject: [PATCH] Headless real-character sim + new-feature exercise harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/expedition-sim/main.go | 42 ++++ internal/plugin/exercise_prod_test.go | 290 ++++++++++++++++++++++++++ internal/plugin/expedition_sim.go | 32 +++ internal/plugin/plugin.go | 8 + 4 files changed, 372 insertions(+) create mode 100644 internal/plugin/exercise_prod_test.go diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index da6a0cb..c29083c 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -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 diff --git a/internal/plugin/exercise_prod_test.go b/internal/plugin/exercise_prod_test.go new file mode 100644 index 0000000..427414d --- /dev/null +++ b/internal/plugin/exercise_prod_test.go @@ -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 @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") +} diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 6bb96db..cca3fe7 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -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 diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 7f93839..f4d3b0d 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -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{