Long expeditions D3: compact autopilot auto-resolves boss rooms

Drops the boss carve-out in the compact (background autorun) path so
boss rooms resolve through the same forward-sim engine elites already
use. A `bossSafetyGate` (HP < 80% / supplies < daily burn /
exhaustion >= 3) guards the engage; when it trips, the walk returns
`stopBossSafety` and the autorun ticker force-pitches a rest camp via
`pitchBossSafetyCamp` (bypasses the normal scheduler's HP threshold
and its RoomBoss room-type block; keeps event-anchored night handling).

`resolveCombatRoom` now selects monster + label + loot drop by
`run.CurrentRoomType()` so the same callsite handles boss kills
(zone.Boss bestiary, "Boss — name down", boss-loot drop, elite-tier
threat bump). The walk loop only breaks at elite/boss doorways when
`!compact`; compact lets the next iteration auto-resolve.

Foreground `!fight` and `!expedition run` are unchanged. Sim path is
unaffected — stopBossSafety falls into the default soft-stop branch.
This commit is contained in:
prosolis
2026-05-27 18:56:52 -07:00
parent c729433353
commit 68ed8e7c60
6 changed files with 218 additions and 27 deletions

View File

@@ -83,11 +83,12 @@ Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_cam
### D3 — Autonomous elite + boss engagement ### D3 — Autonomous elite + boss engagement
**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch). **Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch).
**Work:**
- Background autopilot (compact==true): drop the `prev == RoomBoss` carve-out. Boss auto-resolves through the same forward-sim path as elites. **Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment.
- **Safety gate before boss**: if HP < 80% of max OR supplies < 1 day's burn OR active "low" status, autopilot pitches a camp instead of engaging. Retry next tick.
- **Loss handling**: a death on auto-resolved boss still kicks the player-death narration and ends the run. No special treatment. The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line.
- Foreground `!fight` still works (manual override). Foreground `!expedition run` still stops at boss for the player who wants to watch.
When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk).
**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads. **Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads.

View File

@@ -642,7 +642,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
// Doorway/blocked stops fire *before* the current room actually // Doorway/blocked stops fire *before* the current room actually
// resolved — those don't count as a walked room. Everything else // resolved — those don't count as a walked room. Everything else
// (OK, fork after a clear, ended after combat, complete) does. // (OK, fork after a clear, ended after combat, complete) does.
if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss { // stopBossSafety also fires at the doorway (compact autopilot
// bailed before engaging), so it doesn't count either.
if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss && res.reason != stopBossSafety {
rooms++ rooms++
} }
@@ -706,15 +708,16 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
exp = fresh exp = fresh
} }
// Arrived at a Boss doorway: stop here. The "Room X/Y — Boss. // Arrived at an Elite/Boss doorway. Foreground stops here so the
// !fight when ready." line in res.final already tells the player // player can decide; the "Room X/Y — Boss. !fight when ready."
// what to do; another loop iteration would just hit the gate and // line in res.final already tells them what to do.
// emit a duplicate "Room X/Y — Boss" message.
// //
// For Elite + non-compact, do the same. In compact mode we let // In compact mode (background autopilot, long-expedition D2/D3)
// the next iteration run because the gate will auto-resolve the // we let the next iteration run because the gate will auto-
// elite inline (which is the whole point of compact mode). // resolve the encounter inline — elite always, boss when the
if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) { // safety check passes (otherwise the gate returns stopBossSafety
// and the autorun ticker pitches a rest camp).
if !compact && (res.nextRoomType == RoomBoss || res.nextRoomType == RoomElite) {
r := stopBoss r := stopBoss
if res.nextRoomType == RoomElite { if res.nextRoomType == RoomElite {
r = stopElite r = stopElite
@@ -823,6 +826,8 @@ func autopilotFooter(reason stopReason, rooms int) string {
return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr) return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
case stopBoss: case stopBoss:
return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr) return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr)
case stopBossSafety:
return "" // res.final already carries the held-back-from-boss line
case stopEnded: case stopEnded:
return "" // death narration is the final; no footer return "" // death narration is the final; no footer
case stopComplete: case stopComplete:

View File

@@ -310,6 +310,7 @@ func TestAutopilotFooter_Reasons(t *testing.T) {
{stopEnded, "", false}, {stopEnded, "", false},
{stopComplete, "", false}, {stopComplete, "", false},
{stopBlocked, "", false}, {stopBlocked, "", false},
{stopBossSafety, "", false}, // res.final carries the held-back line
} }
for _, c := range cases { for _, c := range cases {
got := autopilotFooter(c.r, 3) got := autopilotFooter(c.r, 3)
@@ -323,6 +324,59 @@ func TestAutopilotFooter_Reasons(t *testing.T) {
} }
} }
// TestBossSafetyGate covers all three trip conditions (HP, supplies,
// exhaustion) and the all-clear case. The gate is the D3 boss carve-out
// replacement — compact autopilot asks it before engaging the boss.
func TestBossSafetyGate(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@exp-boss-safety-gate:example")
expeditionCmdTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
healthyExp := &Expedition{
Supplies: ExpeditionSupplies{Current: 10, DailyBurn: 1},
}
// All-clear baseline: full HP, supplies fat, no exhaustion → no block.
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = c.HPMax
c.Exhaustion = 0
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if msg, blocked := bossSafetyGate(uid, healthyExp); blocked {
t.Fatalf("expected healthy party to pass gate, blocked with %q", msg)
}
// HP below 80% → block.
c.HPCurrent = int(float64(c.HPMax) * 0.5)
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "HP") {
t.Errorf("HP gate: blocked=%v msg=%q", blocked, msg)
}
// HP healed, but supplies under one day's burn → block.
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
lowSU := &Expedition{Supplies: ExpeditionSupplies{Current: 0.5, DailyBurn: 1.5}}
if msg, blocked := bossSafetyGate(uid, lowSU); !blocked || !strings.Contains(msg, "supplies") {
t.Errorf("SU gate: blocked=%v msg=%q", blocked, msg)
}
// Supplies refilled, but exhaustion ≥ 3 → block.
c.Exhaustion = 3
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if msg, blocked := bossSafetyGate(uid, healthyExp); !blocked || !strings.Contains(msg, "exhaustion") {
t.Errorf("exhaustion gate: blocked=%v msg=%q", blocked, msg)
}
}
func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) { func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) {
setupAuditTestDB(t) setupAuditTestDB(t)
uid := id.UserID("@exp-cmd-nochar:example") uid := id.UserID("@exp-cmd-nochar:example")

View File

@@ -389,8 +389,39 @@ const (
stopBlocked // an active CombatSession blocks the advance stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU) 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
// is at or below this fraction of max. 0.80 ≫ autopilotLowHPPct (0.30) so
// the gate fires well before the player is in real danger; the boss is
// the run's climax beat and we'd rather rest first than chip-trade into it.
const bossSafetyHPPct = 0.80
// bossSafetyExhaustion — gate trips at this level or above. 3 is the 5e
// "disadvantage on attack rolls and saving throws" tier; engaging a boss
// past that is a coin-flip TPK. A standard rest decrements exhaustion by 1,
// so two rest cycles clears a stack of 3 even without a long rest.
const bossSafetyExhaustion = 3
// bossSafetyGate reports whether the compact autopilot should pause before
// engaging the boss. Returns (player-facing reason, true) when blocked.
// Plumbed through the boss/elite branch of advanceOnceWithOpts so the
// scheduler can pitch a rest camp in response (see tryAutoRun).
func bossSafetyGate(userID id.UserID, exp *Expedition) (string, bool) {
cur, max := dndHPSnapshot(userID)
if max > 0 && float64(cur) <= float64(max)*bossSafetyHPPct {
return fmt.Sprintf("HP %d/%d — below %.0f%% boss-engage threshold", cur, max, bossSafetyHPPct*100), true
}
if exp != nil && exp.Supplies.DailyBurn > 0 && exp.Supplies.Current < exp.Supplies.DailyBurn {
return fmt.Sprintf("supplies %.1f/%.1f SU — under a day's burn", exp.Supplies.Current, exp.Supplies.DailyBurn), true
}
if c, _ := LoadDnDCharacter(userID); c != nil && c.Exhaustion >= bossSafetyExhaustion {
return fmt.Sprintf("exhaustion %d — too worn to fight clean", c.Exhaustion), true
}
return "", false
}
// advanceResult bundles the staged narration + dispatch shape of one // advanceResult bundles the staged narration + dispatch shape of one
// advanceOnce step. preStream/intro/phases/final mirror the streamOrSend // advanceOnce step. preStream/intro/phases/final mirror the streamOrSend
// contract — phases nil means "no per-step pacing required". reason tells // contract — phases nil means "no per-step pacing required". reason tells
@@ -487,9 +518,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
// means the fight's done; fall through so the graph clears the room. // means the fight's done; fall through so the graph clears the room.
// //
// compact==true (background autopilot) auto-resolves elite rooms via // compact==true (background autopilot) auto-resolves elite rooms via
// the same forward-sim engine used for exploration combat. Boss still // the same forward-sim engine used for exploration combat. Long-
// pauses regardless — the boss is the run's climax beat and shouldn't // expedition D3 extends the same path to boss rooms — gated by a
// be settled while the player isn't paying attention. // safety check (HP/SU/exhaustion). When the gate trips the walk
// returns stopBossSafety; the autorun layer pitches a rest camp in
// response (see tryAutoRun). Foreground (!compact) still parks the
// player at the doorway for a manual !fight.
var eliteAutoIntro string var eliteAutoIntro string
var eliteAutoPhases []string var eliteAutoPhases []string
var eliteAutoOutcome string var eliteAutoOutcome string
@@ -501,7 +535,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error()) return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error())
} }
if sess == nil || sess.Status != CombatStatusWon { if sess == nil || sess.Status != CombatStatusWon {
if prev == RoomBoss || !compact { if !compact {
kind := "Elite" kind := "Elite"
r := stopElite r := stopElite
if prev == RoomBoss { if prev == RoomBoss {
@@ -514,10 +548,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
reason: r, reason: r,
}, nil }, nil
} }
// Compact-mode elite auto-resolve. if prev == RoomBoss {
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, true, true) // Cheap extra read — only fires on the boss doorway tick.
exp, _ := getActiveExpedition(ctx.Sender)
if reason, blocked := bossSafetyGate(ctx.Sender, exp); blocked {
return advanceResult{
final: fmt.Sprintf(
"⏸ **Autopilot held back from the boss** — %s. Pitching a rest camp; will re-engage after recovery.",
reason),
reason: stopBossSafety,
}, nil
}
}
// Compact-mode elite/boss auto-resolve. resolveCombatRoom
// selects monster + label by run.CurrentRoomType().
ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, prev == RoomElite, true)
if aerr != nil { if aerr != nil {
return advanceResult{}, fmt.Errorf("Couldn't auto-resolve elite: %s", aerr.Error()) return advanceResult{}, fmt.Errorf("Couldn't auto-resolve %s: %s", strings.ToLower(string(prev)), aerr.Error())
} }
if aended { if aended {
return advanceResult{ return advanceResult{
@@ -890,9 +937,28 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
// Phases will be nil only on a "no roster" skip — caller treats that as a // Phases will be nil only on a "no roster" skip — caller treats that as a
// non-paced fallthrough. // non-paced fallthrough.
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite, compact bool) (intro string, phases []string, outcome string, ended bool, err error) { func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite) // Long-expedition D3 — compact autopilot now auto-resolves boss rooms
// too. The room-type drives monster selection (boss room → zone.Boss
// bestiary entry; exploration/elite → roster pick). Foreground boss
// combat is still the manual !fight path; resolveRoom() doesn't
// dispatch for RoomBoss outside compact.
isBoss := run.CurrentRoomType() == RoomBoss
var monster DnDMonsterTemplate
var ok bool
if isBoss {
monster, ok = dndBestiary[zone.Boss.BestiaryID]
} else {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
}
if !ok { if !ok {
outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]) kind := "exploration"
switch {
case isBoss:
kind = "boss"
case elite:
kind = "elite"
}
outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", kind)
return return
} }
preHP, _ := dndHPSnapshot(userID) preHP, _ := dndHPSnapshot(userID)
@@ -910,7 +976,10 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
hpDelta := preHP - postHP hpDelta := preHP - postHP
var ob strings.Builder var ob strings.Builder
label := monster.Name label := monster.Name
if elite { switch {
case isBoss:
label = "👑 Boss — " + monster.Name
case elite:
label = "Elite " + monster.Name label = "Elite " + monster.Name
} }
ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP)) ob.WriteString(fmt.Sprintf("⚔️ **%s** down — HP %d→%d", label, preHP, postHP))
@@ -919,8 +988,8 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
} }
ob.WriteString(".") ob.WriteString(".")
recordZoneKillForUser(userID, monster.ID) recordZoneKillForUser(userID, monster.ID)
applyRoomCombatThreatForUser(userID, elite) applyRoomCombatThreatForUser(userID, elite || isBoss)
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" {
ob.WriteString(" ") ob.WriteString(" ")
ob.WriteString(drop) ob.WriteString(drop)
} }

View File

@@ -334,6 +334,60 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav
return out return out
} }
// pitchBossSafetyCamp force-pitches a rest camp after the compact
// autopilot bailed out before the boss (stopBossSafety). Bypasses the
// decideAutopilotCamp HP threshold and its RoomBoss room-type block —
// the boss doorway is *exactly* where this camp belongs. Picks the best
// kind the supplies will pay for (Standard > Rough); returns "" if even
// Rough is too expensive (extract-soon territory). The returned block is
// appended to the autorun DM by the caller.
//
// Day-rollover handling mirrors decideAutopilotCamp: when enough real
// time has elapsed since the last briefing on an event-anchored
// expedition, the pitch carries Night=true and runs the burn/drift
// alongside the rest.
func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string {
if exp == nil || exp.Status != ExpeditionStatusActive {
return ""
}
if exp.Camp != nil && exp.Camp.Active {
// Already resting — nothing to pitch. The dwell window will pass
// and the next autorun tick will retry the boss engagement.
return ""
}
kind := CampTypeStandard
if exp.Supplies.Current < campSupplyCost[kind] {
kind = CampTypeRough
}
if exp.Supplies.Current < campSupplyCost[kind] {
// No SU even for Rough — autopilot can't help here. The walk's
// preflight will surface low-SU on the next tick if the player
// doesn't extract.
return ""
}
night := false
if isEventAnchored(exp) {
var since time.Duration
if exp.LastBriefingAt != nil {
since = time.Since(*exp.LastBriefingAt)
} else {
since = time.Since(exp.StartDate)
}
night = since >= nightCampWindow
}
d := autoCampDecision{
Kind: kind,
Reason: "boss-safety hold — resting before re-engaging",
Night: night,
}
block, err := p.pitchAutopilotCamp(exp, d)
if err != nil {
slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err)
return ""
}
return block
}
// breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled // breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled
// for minAutoCampDwell. Returns true when it broke a camp (so the // for minAutoCampDwell. Returns true when it broke a camp (so the
// caller knows the walk should proceed this tick). Player-pitched camps // caller knows the walk should proceed this tick). Player-pitched camps

View File

@@ -178,7 +178,15 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// own preflight handles low-SU pauses; the scheduler stays out of // own preflight handles low-SU pauses; the scheduler stays out of
// fork/combat/death/complete branches by checking r.reason. // fork/combat/death/complete branches by checking r.reason.
campBlock := "" campBlock := ""
if r.reason != stopEnded && r.reason != stopComplete && if r.reason == stopBossSafety {
// D3 — the boss-engage gate tripped. Force-pitch a rest camp
// regardless of decideAutopilotCamp's normal HP threshold and
// its RoomBoss block. Next tick past dwell retries the boss.
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive {
campBlock = p.pitchBossSafetyCamp(fresh)
}
} else if r.reason != stopEnded && r.reason != stopComplete &&
r.reason != stopBlocked && r.reason != stopFork { r.reason != stopBlocked && r.reason != stopFork {
if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil &&
fresh.Status == ExpeditionStatusActive { fresh.Status == ExpeditionStatusActive {