mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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
394 lines
13 KiB
Go
394 lines
13 KiB
Go
// 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)
|
||
}
|