From f3e7a33435b6cc5e8de3c5fe2e7eae05c25df44f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 17 May 2026 13:12:50 -0700 Subject: [PATCH] expedition-sim: matrix mode for batch corpora MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -matrix sweeps the cartesian product of -classes × -levels × -zones with -runs replicates per cell, fresh sqlite DB per run, emitting one JSON object per stdout line. Log field is suppressed by default in matrix mode (override with -log=true) since a single matrix cell's log can easily dwarf the per-run summary. Default -cap shrunk from 200 to 50 — RunExpedition now counts autopilot bursts (not !expedition run invocations), and 50 bursts is enough to clear a tier-1 zone with headroom. --- cmd/expedition-sim/main.go | 159 +++++++++++++++++++++++++++++-------- 1 file changed, 128 insertions(+), 31 deletions(-) diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 0e36feb..133449b 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -2,12 +2,14 @@ // analysis. Re-uses production plugin paths against a fresh sqlite DB so // outcomes mirror what live players hit. // -// Usage: expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] -// [-bank 1000] [-cap 200] [-data DIR] +// Single run: +// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] +// [-bank 1000] [-cap 50] [-log] [-data DIR] // -// Defaults run one L5 fighter through goblin_warrens and print a JSON -// SimResult to stdout. Per-room logs and matrix-mode batches land in a -// follow-up; this binary is the scaffold the analysis stack hangs off. +// Matrix mode (cartesian sweep over classes × levels × zones × N runs, +// one JSON object per stdout line, log suppressed by default): +// expedition-sim -matrix -classes fighter,mage -levels 5,10 \ +// -zones goblin_warrens,wolf_den -runs 20 package main import ( @@ -15,6 +17,8 @@ import ( "flag" "fmt" "os" + "strconv" + "strings" "gogobee/internal/plugin" @@ -23,17 +27,42 @@ import ( func main() { var ( - class = flag.String("class", "fighter", "DnD class id (fighter, mage, cleric, …)") - level = flag.Int("level", 5, "character level") - zone = flag.String("zone", "goblin_warrens", "zone id (see internal/plugin/dnd_zone_registry.go)") + class = flag.String("class", "fighter", "DnD class id (single-run mode)") + level = flag.Int("level", 5, "character level (single-run mode)") + zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)") bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting") - cap = flag.Int("cap", 200, "max !expedition run invocations per expedition") - dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir)") - userTag = flag.String("user", "@sim:expedition", "synthetic user id") + cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)") + 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)") + 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") + classes = flag.String("classes", "", "comma-separated class ids (matrix mode)") + levels = flag.String("levels", "", "comma-separated levels (matrix mode)") + zones = flag.String("zones", "", "comma-separated zone ids (matrix mode)") + runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)") ) flag.Parse() - dir := *dataDir + if *matrix { + // Matrix default: drop log to keep stdout manageable; explicit + // -log=true overrides. + includeLog := false + // Flag.Lookup tells us whether the user explicitly set -log. + flag.Visit(func(f *flag.Flag) { + if f.Name == "log" { + includeLog = *logFlag + } + }) + runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog) + return + } + + runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag) +} + +func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) { + dir := dataDir if dir == "" { var err error dir, err = os.MkdirTemp("", "expedition-sim-") @@ -43,37 +72,105 @@ func main() { defer os.RemoveAll(dir) } - runner, err := plugin.NewSimRunner(dir) + res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap) if err != nil { - fail("init runner:", err) + if res != nil { + if !includeLog { + res.Log = nil + } + emitIndented(res) + } + fail("run:", err) + } + if !includeLog { + res.Log = nil + } + emitIndented(res) +} + +func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) { + cs := splitNonEmpty(classes) + ls := parseLevels(levels) + zs := splitNonEmpty(zones) + if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 { + fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0") + } + enc := json.NewEncoder(os.Stdout) + for _, c := range cs { + for _, lv := range ls { + for _, z := range zs { + for r := 0; r < runs; r++ { + dir, err := os.MkdirTemp("", "expedition-sim-") + if err != nil { + fail("mkdir temp:", err) + } + uid := id.UserID(fmt.Sprintf("@sim:%s-l%d-%s-%d", c, lv, z, r)) + res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap) + if res != nil && !includeLog { + res.Log = nil + } + if runErr != nil && res == nil { + // Synthesize a row so the corpus has one line per + // cell regardless of init failures. + res = &plugin.SimResult{ + UserID: string(uid), + Class: c, + Level: lv, + Zone: z, + Outcome: "halted", + } + } + _ = enc.Encode(res) + _ = os.RemoveAll(dir) + } + } + } + } +} + +func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) { + runner, err := plugin.NewSimRunner(dataDir) + if err != nil { + return nil, fmt.Errorf("init runner: %w", err) } defer runner.Close() - uid := id.UserID(*userTag) - if _, err := runner.BuildCharacter(uid, plugin.DnDClass(*class), *level); err != nil { - fail("build character:", err) + if _, err := runner.BuildCharacter(uid, class, level); err != nil { + return nil, fmt.Errorf("build character: %w", err) } - - runner.Euro.Credit(uid, *bank, "expedition-sim bankroll") - - res, err := runner.RunExpedition(uid, plugin.ZoneID(*zone), *cap) - if err != nil { - // RunExpedition returns a partial result alongside the error so the - // outcome of a partial run is still observable. - if res != nil { - emit(res) - } - fail("run expedition:", err) - } - emit(res) + runner.Euro.Credit(uid, bank, "expedition-sim bankroll") + return runner.RunExpedition(uid, zone, cap) } -func emit(res *plugin.SimResult) { +func emitIndented(res *plugin.SimResult) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(res) } +func splitNonEmpty(s string) []string { + parts := strings.Split(s, ",") + out := parts[:0] + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func parseLevels(s string) []int { + var out []int + for _, p := range splitNonEmpty(s) { + n, err := strconv.Atoi(p) + if err != nil { + fail("bad level:", p) + } + out = append(out, n) + } + return out +} + func fail(args ...interface{}) { fmt.Fprintln(os.Stderr, args...) os.Exit(1)