diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 9fd4352..48aeba3 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -13,6 +13,7 @@ package main import ( + "bytes" "encoding/json" "flag" "fmt" @@ -191,14 +192,33 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, args = append(args, "-trace") } cmd := exec.Command(exe, args...) + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf stdout, runErr := cmd.Output() var res plugin.SimResult - if jerr := json.Unmarshal(stdout, &res); jerr != nil { + 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 } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index be45d82..0f57c77 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -572,11 +572,17 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin // autoResolveCombat / simPickCombatAction). Surface the // doorway like the foreground path does, after the safety // gate has had a chance to defer the engagement. + kind := "Elite" r := stopElite if prev == RoomBoss { + kind = "Boss" r = stopBoss } - return advanceResult{reason: r}, nil + return advanceResult{ + final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.", + prevIdx+1, run.TotalRooms, kind), + reason: r, + }, nil } // Compact-mode elite/boss auto-resolve. resolveCombatRoom // selects monster + label by run.CurrentRoomType(). diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 040c3f3..2ace294 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -894,13 +894,18 @@ func simMartialFirstClass(class DnDClass) bool { return false } -// simPickSpiritualWeapon returns "spiritual_weapon" when a cleric should -// open the fight with it: the buff is not already active on the session, -// the spell is prepared, and an L2 slot is available. Returns "" otherwise. -// The buff path in combat_cmd.go folds into BuffSpiritProc/Dmg via -// applyBuffDelta, which the turn engine fires each round via -// spiritWeaponStrike — so one cast is worth more than a single L2 damage -// spell across a multi-round fight. +// simPickSpiritualWeapon returns a !cast argument for Spiritual Weapon +// when a cleric should open the fight with it: the buff is not already +// active on the session, the spell is prepared, and some slot ≥ L2 is +// available. Returns "" otherwise. The buff path in combat_cmd.go folds +// into BuffSpiritProc/Dmg via applyBuffDelta, which the turn engine fires +// each round via spiritWeaponStrike — so one cast is worth more than a +// single L2 damage spell across a multi-round fight. +// +// Slot pick: lowest available slot ≥ 2. Upcasting is +1d8 per 2 slots +// above 2nd, so spending a precious L5 to add a single d8 to the proc is +// not worth burning the bigger slot's damage potential elsewhere; sim +// behaves like a competent player and saves the high slot. func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string { if c == nil || c.Class != ClassCleric || sess == nil { return "" @@ -923,11 +928,18 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) return "" } slots, _ := getSpellSlots(uid) - pair, ok := slots[2] - if !ok || pair[0]-pair[1] <= 0 { - return "" + const simMaxSlot = 5 + for sl := 2; sl <= simMaxSlot; sl++ { + pair, ok := slots[sl] + if !ok || pair[0]-pair[1] <= 0 { + continue + } + if sl == 2 { + return "spiritual_weapon" + } + return fmt.Sprintf("spiritual_weapon --upcast %d", sl) } - return "spiritual_weapon" + return "" } // simPickSpell returns the spell argument a competent player would pass