diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index d00f115..acdfec2 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -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 ` first.") + return p.replyDM(ctx, "No active zone run. Use `!zone enter ` 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 ` 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 ` 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 ` 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 `. You're carrying: "+strings.Join(names, ", ")+".") + return p.replyDM(ctx, "Usage: `!consume `. 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)) } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 0f57c77..6aba28c 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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)) } - diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index d8cd893..47f85f8 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -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 diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index c36db72..2997f1e 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -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 diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 1c949fe..26ef770 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -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.