Files
gogobee/cmd/expedition-sim/main.go
prosolis b167882e3e expedition-sim: -pet-level flag to model a base housing pet
Synthetic sim chars are vanilla base-class (no pet, no subclass), so the
per-round pet attack/deflect/whiff path was never exercised in the sim.
Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the
AdventureCharacter via the normal save path before the run, so combat's
DerivePlayerStats sees HasPet()==true. 0 (default) stays petless.

Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate
38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster
trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread.
2026-05-22 08:28:51 -07:00

189 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// expedition-sim drives synthetic players through expeditions for batch
// analysis. Re-uses production plugin paths against a fresh sqlite DB so
// outcomes mirror what live players hit.
//
// Single run:
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR]
//
// 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 (
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"gogobee/internal/plugin"
"maunium.net/go/mautrix/id"
)
func main() {
var (
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", 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)")
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
)
flag.Parse()
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
plugin.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel)
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-")
if err != nil {
fail("mkdir temp:", err)
}
defer os.RemoveAll(dir)
}
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap)
if err != nil {
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()
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")
return runner.RunExpedition(uid, zone, cap)
}
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)
}