J3 D8-review: surface sim subprocess errors + SW upcast + doorway msg

- expedition-sim matrix worker now captures child stderr and dumps
  runErr/stderr/stdout-snippet on failure so halted rows have a cause.
- simPickSpiritualWeapon walks slots 2..5 and upcasts when L2 is spent
  instead of silently skipping the spell on high-level clerics.
- advanceOnceWithOpts !inlineBossCombat branch now emits the same
  "Room X/Y — Boss/Elite. Type !fight to engage." line as foreground.
This commit is contained in:
prosolis
2026-05-28 00:53:25 -07:00
parent da94d51857
commit 63ad423b79
3 changed files with 51 additions and 13 deletions

View File

@@ -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
}

View File

@@ -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().

View File

@@ -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