mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Merge sim-real-char-harness: headless real-char sim + feature exercise harness
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -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
|
||||
|
||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||
// before opening it, so even the copy is left pristine.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||
//
|
||||
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||
// error in one is reported and the battery continues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||
if srcDir == "" {
|
||||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||
t.Skipf("no gogobee.db in %s", srcDir)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(tmp); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||
// so the same DB copy exercises identically regardless of which week we run.
|
||||
simOmenDisabled = true
|
||||
simAutoArmEnabled = true
|
||||
|
||||
euro := NewEuroPlugin(nil)
|
||||
xp := NewXPPlugin(nil)
|
||||
p := NewAdventurePlugin(nil, euro, xp)
|
||||
sink := &captureSink{}
|
||||
p.Sink = sink
|
||||
mark := 0
|
||||
drain := func() {
|
||||
for _, m := range sink.msgs[mark:] {
|
||||
who := string(m.ToUser)
|
||||
if who == "" {
|
||||
who = "room:" + string(m.ToRoom)
|
||||
}
|
||||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||
}
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
exercise := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
}()
|
||||
t.Logf("──────── %s ────────", name)
|
||||
fn()
|
||||
drain()
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) == 0 {
|
||||
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||
}
|
||||
t.Logf("runnable characters: %d", len(chars))
|
||||
for _, c := range chars {
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||
}
|
||||
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||
}
|
||||
|
||||
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||
for _, c := range chars {
|
||||
xp, _ := loadRenownXP(c.uid)
|
||||
lvl := renownLevelForUser(c.uid)
|
||||
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||
}
|
||||
// Push the first character up a renown level to fire the announce path.
|
||||
c0 := chars[0]
|
||||
before, after, err := addRenownXP(c0.uid, 500)
|
||||
if err != nil {
|
||||
t.Logf(" addRenownXP: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||
})
|
||||
|
||||
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||
if err != nil {
|
||||
t.Logf(" spawnWorldBoss: %v", err)
|
||||
}
|
||||
if boss != nil {
|
||||
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||
}
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
for _, c := range chars {
|
||||
t.Logf(" -- %s takes a bout --", c.uid)
|
||||
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||
}
|
||||
t.Logf(" -- pool after the raid --")
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
})
|
||||
|
||||
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters for a duel; skipping")
|
||||
return
|
||||
}
|
||||
a, b := chars[0], chars[1]
|
||||
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||
})
|
||||
|
||||
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||
for _, c := range chars {
|
||||
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||
for _, c := range chars {
|
||||
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||
})
|
||||
|
||||
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||
for _, c := range chars {
|
||||
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters to gift; skipping")
|
||||
return
|
||||
}
|
||||
giver, receiver := chars[0], chars[1]
|
||||
item := firstInventoryItem(giver.uid)
|
||||
if item == "" {
|
||||
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||
})
|
||||
|
||||
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||
exercise("N7/B4 Achievements — !achievements", func() {
|
||||
ach := NewAchievementsPlugin(nil, nil)
|
||||
ach.Sink = sink
|
||||
p.SetAchievements(ach)
|
||||
for _, c := range chars {
|
||||
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
type exChar struct {
|
||||
uid id.UserID
|
||||
class string
|
||||
level int
|
||||
}
|
||||
|
||||
// runnableChars lists characters that have both a completed dnd_character (class
|
||||
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||
func runnableChars(t *testing.T) []exChar {
|
||||
t.Helper()
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT d.user_id, d.class, d.dnd_level
|
||||
FROM dnd_character d
|
||||
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||
WHERE d.class != ''`)
|
||||
if err != nil {
|
||||
t.Fatalf("query chars: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []exChar
|
||||
for rows.Next() {
|
||||
var c exChar
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.uid = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||
return out
|
||||
}
|
||||
|
||||
func healToFull(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||
return
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInventoryItem(uid id.UserID) string {
|
||||
d := db.Get()
|
||||
var name string
|
||||
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||
_ = d.QueryRow(
|
||||
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||
string(uid),
|
||||
).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func copyIfPresent(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return // wal/shm may be absent — fine
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = " " + l
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user