J3 D8-f #1: route prod autopilot boss/elite through the turn engine

Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
This commit is contained in:
prosolis
2026-05-28 15:30:48 -07:00
parent b80de43db1
commit a46b773750
5 changed files with 192 additions and 81 deletions

View File

@@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string {
return fmt.Sprintf("room%d", roomIdx)
}
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
// turn-engine combat commands route all their DMs through here so the
// background autopilot can drive a boss/elite fight on the real engine
// (long-expedition D8-f) without spamming the player a DM per round — the
// state mutations (HP, XP, threat, run-clear) still happen; only the
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
if ctx.Silent {
return nil
}
return p.SendDM(ctx.Sender, text)
}
// ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
}
roomType := run.CurrentRoomType()
if roomType != RoomElite && roomType != RoomBoss {
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
}
encID := encounterIDForRoom(run.CurrentRoom)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status {
case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender))
return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
}
}
@@ -68,26 +81,26 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
}
if !ok {
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
}
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
}
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 {
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
}
enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil {
if err == ErrCombatSessionAlreadyActive {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
}
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
@@ -118,7 +131,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
}
b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String())
return p.replyDM(ctx, b.String())
}
// ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +151,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// renderRoundResult turns a resolved round into the player-facing block: the
@@ -393,35 +406,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
// Race: the fight closed between the route check and the lock.
return p.SendDM(ctx.Sender, "You're not in a fight anymore.")
return p.replyDM(ctx, "You're not in a fight anymore.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
}
if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
}
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
return p.replyDM(ctx, errMsg)
}
if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
var eff *turnActionEffect
@@ -433,11 +446,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +458,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
label := spell.Name + " — active"
if d.heal > 0 {
@@ -458,11 +471,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
eff = &turnActionEffect{
Label: out.Desc,
@@ -478,9 +491,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// chargeSpellCost debits a spell's material component and leveled slot for a
@@ -551,37 +564,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
}
inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args)
if args == "" {
if len(inv) == 0 {
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
}
names := make([]string, len(inv))
for i, c := range inv {
names[i] = c.Def.Name
}
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
}
item, ambig := matchConsumable(inv, args)
if ambig != "" {
return p.SendDM(ctx.Sender, ambig)
return p.replyDM(ctx, ambig)
}
if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
}
def := item.Def
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
eff := &turnActionEffect{Action: "use_consumable"}
@@ -600,13 +613,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal
@@ -615,7 +628,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
// Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them
@@ -623,5 +636,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}

View File

@@ -380,16 +380,16 @@ func roomGlyph(rt RoomType) string {
type stopReason int
const (
stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
)
// bossSafetyHPPct — compact-autopilot won't engage a boss while current HP
@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (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.
// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// multiattack). false returns stopBoss/stopElite after the safety gate so
// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// the fight via the regular !fight / !attack engine. Both the headless sim
// and the production autorun (long-expedition D8-f) now pass false so the
// real engine (with multiattack) resolves the encounter and simPickSpell
// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it.
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
}
}
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.
// Background caller wants to drive the fight itself via the
// turn engine (autoDriveCombat / pickAutoCombatAction).
// 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 {
@@ -953,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
// resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1139,7 +1142,7 @@ type BossOutcomeInputs struct {
Result CombatResult
PreHP, PostHP, MaxHP int
PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration
Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat
Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat
// Caller-supplied headlines so arena and zone read in their own voice.
// DefeatHeadline is the full sentence shown after the PlayerDeath
@@ -1287,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms))
}

View File

@@ -138,6 +138,84 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
return ids, rows.Err()
}
// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a
// single background tick. With autoRunRoomCap == 3 rooms/tick the loop
// can realistically only hit a couple doorways; the cap is a backstop
// against a pathological state where a fight wins but the next walk
// re-presents the same doorway.
const autoRunCombatSegmentCap = 8
// runAutopilotWalkDriven runs the compact background walk and, when it
// halts at a boss/elite doorway, drives that fight through the real turn
// engine (manual `!fight` parity — long-expedition D8-f) before resuming
// the walk. It loops walk→fight→walk so one tick still covers up to
// maxRooms rooms, exactly as the old inline-boss path did, but bosses now
// face the player's full kit against the enemy's full multiattack profile
// instead of the rosier inline SimulateCombat path.
//
// Combat narration is suppressed via a silent ctx — the day digest
// summarizes the outcome, matching the rest of the compact autopilot
// surface. The returned result carries the cumulative room count and the
// reason of whichever non-combat stop ended the loop.
func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult {
silent := ctx
silent.Silent = true
total := 0
for seg := 0; seg < autoRunCombatSegmentCap; seg++ {
budget := maxRooms - total
if budget <= 0 {
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
r := p.runAutopilotWalk(ctx, budget, true, false)
if r.initErr != "" {
return r
}
total += r.rooms
r.rooms = total
if r.reason != stopBoss && r.reason != stopElite {
return r
}
// Standing at an elite/boss doorway — drive the fight on the turn
// engine. handleFightCmd opens the session at the current doorway;
// autoDriveCombat loops until it resolves.
won, err := p.autoDriveCombat(silent)
if err != nil {
slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err)
// Leave the doorway stop in place; the next tick retries the
// engagement after the cooldown.
return r
}
if won {
// The won session is recorded; the next walk advances the now-
// cleared room (a boss win surfaces as stopComplete, an elite as
// a normal continue). Loop.
continue
}
// Lost: the turn engine never voluntarily flees, so a non-win means
// the party fell. finishCombatSession (CombatStatusLost) already
// abandoned the run and force-extracted the expedition; surface it
// as a death so the digest + pet-emergence seam fire.
if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 {
r.reason = stopEnded
r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total))
return r
}
// Alive but the session didn't open / resolve to a win (rare —
// bestiary miss or stall). Leave the doorway stop; retry next tick.
return r
}
// Segment cap hit — stop cleanly rather than spin.
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
// roomsWalkedPhrase renders "N room(s)" for autopilot footers.
func roomsWalkedPhrase(rooms int) string {
if rooms == 1 {
return "1 room"
}
return fmt.Sprintf("%d rooms", rooms)
}
// tryAutoRun claims the slot for this expedition, runs one background
// walk, and DMs the result if the suppression rules say to. The CAS-
// update is the only persistent side effect on the autorun column —
@@ -170,7 +248,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
}
uid := id.UserID(e.UserID)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true)
// D8-f — boss/elite encounters route through the real turn engine for
// manual `!fight` parity (enemy multiattack included). Trash mobs still
// auto-resolve on the fast inline path inside the walk.
r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap)
if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil

View File

@@ -123,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
// stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire
// during autoResolveCombat. Counts are deliberately modest — a real
// during autoDriveCombat. Counts are deliberately modest — a real
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class
// power gaps.
@@ -281,8 +281,8 @@ type SimResult struct {
// resource). YieldsByName breaks that total down by resource name
// for tier/per-resource calibration. Both are read from
// adventure_inventory at end-of-run.
YieldCount int
YieldsByName map[string]int
YieldCount int
YieldsByName map[string]int
// Combats holds a per-combat trace for every fight the synthetic
// player entered during the expedition (boss + elites + patrols).
// Used by post-hoc analysis to dig into class-survival walls
@@ -480,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx)
killed, err := s.P.autoDriveCombat(ctx)
if err != nil {
res.Outcome = "halted"
res.StopCode = "combat:" + err.Error()
@@ -742,16 +742,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out
}
// autoResolveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true
// when the player won (enemy dead, room cleared), false when the
// player lost or fled. autoCombatRoundCap is a safety cap against
// autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack/!cast/!consume until the combat session resolves.
// Returns true when the player won (enemy dead, room cleared), false when
// the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is
// strictly monotone in HP).
//
// Shared by the headless sim and the production background autopilot
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
// per-round DM narration so the autorun digest can summarize the fight
// without spamming the player a message per round. Driving the real
// !fight/!attack engine here is what gives autopilot bosses true parity
// with manual `!fight` — including enemy multiattack, which the old
// inline SimulateCombat path ignored.
const autoCombatRoundCap = 200
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil {
func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err)
}
sess, err := getActiveCombatSession(ctx.Sender)
@@ -779,15 +787,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled:
return false, nil
}
kind, arg := s.simPickCombatAction(ctx.Sender, cur)
kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error
switch kind {
case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg)
dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg)
dispatchErr = p.handleCombatCastCmd(ctx, arg)
default:
dispatchErr = s.P.handleAttackCmd(ctx)
dispatchErr = p.handleAttackCmd(ctx)
}
if dispatchErr != nil {
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
@@ -835,7 +843,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
// one more big hit, not so early that a 1-HP scratch burns a potion.
const simHealHPThresholdPct = 40
// simPickCombatAction is the sim's per-turn decision tree, mirroring
// pickAutoCombatAction is the per-turn decision tree, mirroring
// what a competent prod player would type:
//
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal
@@ -854,14 +862,14 @@ const simHealHPThresholdPct = 40
//
// Pre-J2a the sim looped !attack only, which underweighted every caster
// class — see sim_results/j2_findings.md for the trace evidence.
func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil {
return "attack", ""
}
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP {
inv := s.P.loadConsumableInventory(uid)
inv := p.loadConsumableInventory(uid)
for _, it := range inv {
if it.Def.Effect == EffectHeal {
return "consume", it.Def.Name

View File

@@ -41,6 +41,13 @@ type MessageContext struct {
// routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID.
OriginRoomID id.RoomID
// Silent suppresses player-facing replies for handlers that honor it
// (currently the turn-engine combat commands via replyDM). Set by the
// background autopilot when it drives a boss/elite fight through the
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
// the day digest summarizes the outcome, so the per-round narration is
// dropped rather than DM'd round-by-round.
Silent bool
}
// ReactionContext holds the context for a reaction event.