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

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