Files
gogobee/cmd/expedition-sim/main.go
prosolis d538f91cf7 Combat engine: pay off the N-body debt, and hire Pete
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.

The party golden did not exist (§5)
  Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
  nothing. That is why a healer class that cannot heal shipped without a test
  going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
  weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
  N-body path can never quietly stop being a superset of the balance corpus.
  Regenerate only on purpose: -update-party.

No action could target another seat (§1)
  Every heal in the engine was self-scoped. A party cleric could not put one hit
  point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
  @user` and `--target @user` -- the latter has been advertised in !help and
  silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
  picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
  behave like competent ones. It will not raise the dead.

Corpses kept buffing the boss (§2b)
  enemyActionsThisRound counted len(st.actors), dead included -- so a party that
  lost a member kept paying for them, and the survivor faced a boss still swinging
  at two-player cadence, alone. A death spiral with the arrow pointing the wrong
  way. Now counts livingActors(). Party golden moved deliberately for this.

An engine-driven seat was a bool any command could clear (§3)
  autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
  a companion's own auto-played move arrived at beginCombatTurn looking like a
  player returning to the keyboard and cleared the latch that was moving him. He
  then stood in the fight doing nothing while the boss he had inflated killed
  everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
  command clears, and the driver calls driveEngineSeat instead of impersonating.

"The party" could be empty (§4)
  A solo expedition has no expedition_party rows, so asking the roster who was in
  the party answered "nobody" -- and every caller fell back to something plausible.
  That is how the companion got hired at level 1 for exactly the player the feature
  exists for. expeditionParty()/partyHumans() always include the owner.

The companion himself (!expedition hire [class] / !expedition dismiss)
  Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
  NPC seat and must never become a player: no player_meta, no dnd_character, no
  inventory, no DM room -- mint him a player_meta row and
  ensureDnDCharacterForCombat will auto-build the news bot a real character, and
  he starts appearing in the graveyard and filing death notices about himself.
  Mail and seats are different sets: he fights, he does not get written to.

Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.

The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.

Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 12:39:01 -07:00

394 lines
13 KiB
Go
Raw Permalink 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]
//
// Party run (N3/P7 — the leader is -class; followers clone it unless named):
//
// expedition-sim -party 3 -level 15 -zone dragons_lair
// expedition-sim -party 2 -class cleric -party-classes fighter -level 16 ...
//
// 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 (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"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)")
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")
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)")
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
)
flag.Parse()
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
if *party < 1 || *party > plugin.ExpeditionPartyMax {
fail("party must be 1-", plugin.ExpeditionPartyMax, ", got", *party)
}
followers := followerClasses(*class, *party, *partyClasses)
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, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
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, *companion)
}
// 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
// answer a different question than the one asked.
func followerClasses(leaderClass string, party int, spec string) []string {
n := party - 1
if n == 0 {
if strings.TrimSpace(spec) != "" {
fail("-party-classes given but -party is 1")
}
return nil
}
if strings.TrimSpace(spec) == "" {
out := make([]string, n)
for i := range out {
out[i] = leaderClass
}
return out
}
out := splitNonEmpty(spec)
if len(out) != n {
fail(fmt.Sprintf("-party %d needs %d follower classes, got %d", party, n, len(out)))
}
return out
}
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string, companion string) {
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, days, followers, companion)
if err != nil {
if res != nil {
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
fail("run:", err)
}
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
// matrixJob is one (class, level, zone, replicate-index) cell of the
// matrix sweep. Each job is run by a worker as a single-run subprocess so
// it gets its own SQLite handle — the plugin package's db.* globals
// preclude in-process parallelism.
type matrixJob struct {
class string
level int
zone string
rep int
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
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")
}
if jobs <= 0 {
jobs = runtime.NumCPU()
}
exe, err := os.Executable()
if err != nil {
fail("os.Executable:", err)
}
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
for _, c := range cs {
for _, lv := range ls {
for _, z := range zs {
for r := 0; r < runs; r++ {
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
}
}
}
}
workCh := make(chan matrixJob)
resCh := make(chan *plugin.SimResult, len(work))
var wg sync.WaitGroup
for i := 0; i < jobs; i++ {
wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
}
go func() {
for _, j := range work {
workCh <- j
}
close(workCh)
}()
go func() {
wg.Wait()
close(resCh)
}()
enc := json.NewEncoder(os.Stdout)
for r := range resCh {
_ = enc.Encode(r)
}
}
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) {
defer wg.Done()
for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
dir, err := os.MkdirTemp("", "expedition-sim-")
if err != nil {
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
continue
}
args := []string{
"-class", j.class,
"-level", strconv.Itoa(j.level),
"-zone", j.zone,
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
"-cap", strconv.Itoa(cap),
"-days", strconv.Itoa(days),
"-data", dir,
"-user", uid,
fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party),
}
// Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" {
args = append(args, "-party-classes", partyClasses)
}
if companion != "" {
args = append(args, "-companion", companion)
}
if trace {
args = append(args, "-trace")
}
cmd := exec.Command(exe, args...)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, runErr := cmd.Output()
var res plugin.SimResult
jerr := json.Unmarshal(stdout, &res)
if jerr != nil {
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
}
if runErr != nil && res.Outcome == "" {
res.Outcome = "halted"
}
// Surface subprocess failures: parse error with non-empty stdout
// (corrupted JSON) and non-zero exits both get a stderr dump so the
// user sees the underlying cause instead of just a halted row.
if jerr != nil || runErr != nil {
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
if stderrBuf.Len() > 0 {
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
}
if jerr != nil && len(stdout) > 0 {
snip := stdout
if len(snip) > 200 {
snip = snip[:200]
}
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
}
}
if !includeLog {
res.Log = nil
}
out <- &res
_ = os.RemoveAll(dir)
}
}
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string, companion string) (*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")
// Followers share the leader's level — a party is not a carry (the tier
// gate refuses an under-levelled invitee anyway) — and each buys their own
// loadout, so each needs their own bankroll.
var members []id.UserID
for i, fc := range followers {
muid := followerUserID(uid, i)
if _, err := runner.BuildCharacter(muid, plugin.DnDClass(fc), level); err != nil {
return nil, fmt.Errorf("build follower %d (%s): %w", i+1, fc, err)
}
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid)
}
runner.Companion = companion
return runner.RunPartyExpedition(uid, members, zone, cap, days)
}
// followerUserID derives a follower's Matrix id from the leader's. It must keep
// the ":" — ResolveUser treats a colon as "this is already a full mxid" and
// short-circuits the room-membership lookup the sim has no client for.
func followerUserID(leader id.UserID, i int) id.UserID {
s := string(leader)
local, server, ok := strings.Cut(strings.TrimPrefix(s, "@"), ":")
if !ok {
local, server = strings.TrimPrefix(s, "@"), "sim"
}
return id.UserID(fmt.Sprintf("@%s-m%d:%s", local, i+1, server))
}
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)
}