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) 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 ────────────────────────────────────────────────────────────────── // ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { 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 { 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() roomType := run.CurrentRoomType()
if roomType != RoomElite && roomType != RoomBoss { 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) encID := encounterIDForRoom(run.CurrentRoom)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil { if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status { switch existing.Status {
case CombatStatusActive: 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: 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: 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) monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
} }
if !ok { 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) player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil { 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) playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 { 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 enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP) sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil { if err != nil {
if err == ErrCombatSessionAlreadyActive { 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 // 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("\n")
b.WriteString(combatTurnPrompt(sess)) b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String()) return p.replyDM(ctx, b.String())
} }
// ── !attack / !flee ───────────────────────────────────────────────────────── // ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +151,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { 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 { 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) player, enemy, err := p.combatantsForSession(sess)
if err != nil { 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) events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil { 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 // 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) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { 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 { if sess == nil {
// Race: the fight closed between the route check and the lock. // 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) advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil { 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) { 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))) "%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
} }
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args)) spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" { if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg) return p.replyDM(ctx, errMsg)
} }
if spell.Effect == EffectReaction { 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)) "%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) player, enemy, err := p.combatantsForSession(sess)
if err != nil { 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 var eff *turnActionEffect
@@ -433,11 +446,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel) applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { 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)) "%s has no effect the turn-based engine can apply yet.", spell.Name))
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +458,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = 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" label := spell.Name + " — active"
if d.heal > 0 { if d.heal > 0 {
@@ -458,11 +471,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else { } else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported { 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)) "%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 != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
eff = &turnActionEffect{ eff = &turnActionEffect{
Label: out.Desc, Label: out.Desc,
@@ -478,9 +491,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = 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 // 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) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { 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 { 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) inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args) args = strings.TrimSpace(args)
if args == "" { if args == "" {
if len(inv) == 0 { 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)) names := make([]string, len(inv))
for i, c := range inv { for i, c := range inv {
names[i] = c.Def.Name 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) item, ambig := matchConsumable(inv, args)
if ambig != "" { if ambig != "" {
return p.SendDM(ctx.Sender, ambig) return p.replyDM(ctx, ambig)
} }
if item == nil { 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 def := item.Def
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { 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"} eff := &turnActionEffect{Action: "use_consumable"}
@@ -600,13 +613,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { 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)) "**%s** has no effect the turn-based engine can apply yet.", def.Name))
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
if err != nil { 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.Label = def.Name + " — active"
eff.PlayerHeal = d.heal 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}) events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil { 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 // Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them // 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 { if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) 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

@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
// inlineBossCombat (only consulted when compact==true) selects between the // inlineBossCombat (only consulted when compact==true) selects between the
// two background combat paths at a boss/elite doorway. true keeps the // two background combat paths at a boss/elite doorway. true keeps the
// long-expedition D3 inline auto-resolve (production autorun). false // legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// returns stopBoss/stopElite after the safety gate so a turn-based driver // multiattack). false returns stopBoss/stopElite after the safety gate so
// — currently only the headless sim's autoResolveCombat / simPickCombatAction // a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// — handles the fight via the regular !fight / !attack engine. The sim // the fight via the regular !fight / !attack engine. Both the headless sim
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam. // 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) { func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
} }
} }
if !inlineBossCombat { if !inlineBossCombat {
// Background caller wants to drive the fight itself (sim's // Background caller wants to drive the fight itself via the
// autoResolveCombat / simPickCombatAction). Surface the // turn engine (autoDriveCombat / pickAutoCombatAction).
// doorway like the foreground path does, after the safety // Surface the doorway like the foreground path does, after
// gate has had a chance to defer the engagement. // the safety gate has had a chance to defer the engagement.
kind := "Elite" kind := "Elite"
r := stopElite r := stopElite
if prev == RoomBoss { 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), // resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas, // runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns: // and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block) // intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller // phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary // outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1287,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
"🚪 Abandoned **%s** at room %d/%d. No rewards.", "🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms)) 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() 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 // tryAutoRun claims the slot for this expedition, runs one background
// walk, and DMs the result if the suppression rules say to. The CAS- // walk, and DMs the result if the suppression rules say to. The CAS-
// update is the only persistent side effect on the autorun column — // 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) 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 != "" { if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent. // "no expedition" / "no run" — race with abandon/extract. Silent.
return nil 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 // stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so // + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire // 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 // L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class // mirror that band rather than max-stocking, which would mask class
// power gaps. // power gaps.
@@ -480,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
case stopBoss, stopElite: case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack // Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled). // per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx) killed, err := s.P.autoDriveCombat(ctx)
if err != nil { if err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "combat:" + err.Error() res.StopCode = "combat:" + err.Error()
@@ -742,16 +742,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out return total, out
} }
// autoResolveCombat dispatches !fight at the current elite/boss gate, // autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true // then loops !attack/!cast/!consume until the combat session resolves.
// when the player won (enemy dead, room cleared), false when the // Returns true when the player won (enemy dead, room cleared), false when
// player lost or fled. autoCombatRoundCap is a safety cap against // the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is // pathological stalemates (shouldn't trigger in practice — combat is
// strictly monotone in HP). // 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 const autoCombatRoundCap = 200
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) { func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil { if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err) return false, fmt.Errorf("fight: %w", err)
} }
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
@@ -779,15 +787,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled: case CombatStatusLost, CombatStatusFled:
return false, nil return false, nil
} }
kind, arg := s.simPickCombatAction(ctx.Sender, cur) kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error var dispatchErr error
switch kind { switch kind {
case "consume": case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg) dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast": case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg) dispatchErr = p.handleCombatCastCmd(ctx, arg)
default: default:
dispatchErr = s.P.handleAttackCmd(ctx) dispatchErr = p.handleAttackCmd(ctx)
} }
if dispatchErr != nil { if dispatchErr != nil {
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr) 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. // one more big hit, not so early that a 1-HP scratch burns a potion.
const simHealHPThresholdPct = 40 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: // what a competent prod player would type:
// //
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal // 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 // Pre-J2a the sim looped !attack only, which underweighted every caster
// class — see sim_results/j2_findings.md for the trace evidence. // 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) c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil { if c == nil || sess == nil {
return "attack", "" return "attack", ""
} }
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP { if lowHP {
inv := s.P.loadConsumableInventory(uid) inv := p.loadConsumableInventory(uid)
for _, it := range inv { for _, it := range inv {
if it.Def.Effect == EffectHeal { if it.Def.Effect == EffectHeal {
return "consume", it.Def.Name 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 // routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID. // replies should go here instead of the rewritten RoomID.
OriginRoomID id.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. // ReactionContext holds the context for a reaction event.