diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index f5eae29..9fd4352 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -17,8 +17,11 @@ import ( "flag" "fmt" "os" + "os/exec" + "runtime" "strconv" "strings" + "sync" "gogobee/internal/plugin" @@ -46,6 +49,8 @@ func main() { 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)") + + 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() @@ -66,7 +71,7 @@ func main() { includeLog = *logFlag } }) - runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog) + runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel) return } @@ -100,46 +105,109 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa emitIndented(res) } -func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool) { +// 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 int) { 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) + 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++ { - 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, days) - 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) + 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) + } + 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 int) { + 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), + } + if trace { + args = append(args, "-trace") + } + cmd := exec.Command(exe, args...) + stdout, runErr := cmd.Output() + var res plugin.SimResult + if jerr := json.Unmarshal(stdout, &res); 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" + } + 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) (*plugin.SimResult, error) { runner, err := plugin.NewSimRunner(dataDir) if err != nil { diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index b5cd4d9..b9200c1 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -642,7 +642,7 @@ type autopilotWalkResult struct { // combat already auto-resolves inside resolveCombatRoom; elite/boss // doorways stop here so the player can choose !fight on their own terms. func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { - r := p.runAutopilotWalk(ctx, autopilotRoomCap, false) + r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false) if r.initErr != "" { return p.SendDM(ctx.Sender, r.initErr) } @@ -667,7 +667,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { // run graph / harvest tally / supplies / threat — same as before, just // no streamFlow here. compact==true switches the underlying combat // narration into terse mode and auto-resolves elite (not boss) rooms. -func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult { +func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult { exp, err := getActiveExpedition(ctx.Sender) if err != nil { return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()} @@ -717,7 +717,7 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com } } - res, aerr := p.advanceOnceWithOpts(ctx, compact) + res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat) if aerr != nil { return autopilotWalkResult{initErr: aerr.Error()} } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index ce9c161..be45d82 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -472,10 +472,17 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { // doorways (boss still stops; boss is the climax beat). Foreground // `!expedition run` / `!zone advance` always pass false. func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error) { - return p.advanceOnceWithOpts(ctx, false) + return p.advanceOnceWithOpts(ctx, false, false) } -func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) (advanceResult, error) { +// inlineBossCombat (only consulted when compact==true) selects between the +// two background combat paths at a boss/elite doorway. true keeps the +// long-expedition D3 inline auto-resolve (production autorun). false +// returns stopBoss/stopElite after the safety gate so a turn-based driver +// — currently only the headless sim's autoResolveCombat / simPickCombatAction +// — handles the fight via the regular !fight / !attack engine. The sim +// uses false so simPickSpell actually fires; D8-prereq re-wired this seam. +func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) { run, err := getActiveZoneRun(ctx.Sender) if err != nil { return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error()) @@ -560,6 +567,17 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) }, nil } } + if !inlineBossCombat { + // Background caller wants to drive the fight itself (sim's + // autoResolveCombat / simPickCombatAction). Surface the + // doorway like the foreground path does, after the safety + // gate has had a chance to defer the engagement. + r := stopElite + if prev == RoomBoss { + r = stopBoss + } + return advanceResult{reason: r}, nil + } // Compact-mode elite/boss auto-resolve. resolveCombatRoom // selects monster + label by run.CurrentRoomType(). ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true) diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index ac98695..d8cd893 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -170,7 +170,7 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { } uid := id.UserID(e.UserID) - r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true) + r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true) if r.initErr != "" { // "no expedition" / "no run" — race with abandon/extract. Silent. return nil diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 61b2a72..040c3f3 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -429,7 +429,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays for i := 0; i < walkCap; i++ { simNow = simNow.Add(simWalkInterval) - walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true) + walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false) if walk.initErr != "" { res.Outcome = "halted" res.StopCode = "init:" + walk.initErr