From 68ed8e7c608cca18b7f64a57c85167a80a2159d1 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 18:56:52 -0700 Subject: [PATCH] Long expeditions D3: compact autopilot auto-resolves boss rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gogobee_long_expedition_plan.md | 11 +-- internal/plugin/dnd_expedition_cmd.go | 23 +++--- internal/plugin/dnd_expedition_cmd_test.go | 54 +++++++++++++ internal/plugin/dnd_zone_cmd.go | 93 +++++++++++++++++++--- internal/plugin/expedition_autocamp.go | 54 +++++++++++++ internal/plugin/expedition_autorun.go | 10 ++- 6 files changed, 218 insertions(+), 27 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index c714886..ac6bd45 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -83,11 +83,12 @@ Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_cam ### D3 — Autonomous elite + boss engagement **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. -- **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. -- Foreground `!fight` still works (manual override). Foreground `!expedition run` still stops at boss for the player who wants to watch. + +**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. + +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. + +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. diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 6ad2020..0ba28ec 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -642,7 +642,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com // Doorway/blocked stops fire *before* the current room actually // resolved — those don't count as a walked room. Everything else // (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++ } @@ -706,15 +708,16 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com exp = fresh } - // Arrived at a Boss doorway: stop here. The "Room X/Y — Boss. - // !fight when ready." line in res.final already tells the player - // what to do; another loop iteration would just hit the gate and - // emit a duplicate "Room X/Y — Boss" message. + // Arrived at an Elite/Boss doorway. Foreground stops here so the + // player can decide; the "Room X/Y — Boss. !fight when ready." + // line in res.final already tells them what to do. // - // For Elite + non-compact, do the same. In compact mode we let - // the next iteration run because the gate will auto-resolve the - // elite inline (which is the whole point of compact mode). - if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) { + // In compact mode (background autopilot, long-expedition D2/D3) + // we let the next iteration run because the gate will auto- + // resolve the encounter inline — elite always, boss when the + // 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 if res.nextRoomType == RoomElite { 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) case stopBoss: 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: return "" // death narration is the final; no footer case stopComplete: diff --git a/internal/plugin/dnd_expedition_cmd_test.go b/internal/plugin/dnd_expedition_cmd_test.go index 97d9883..d704c66 100644 --- a/internal/plugin/dnd_expedition_cmd_test.go +++ b/internal/plugin/dnd_expedition_cmd_test.go @@ -310,6 +310,7 @@ func TestAutopilotFooter_Reasons(t *testing.T) { {stopEnded, "", false}, {stopComplete, "", false}, {stopBlocked, "", false}, + {stopBossSafety, "", false}, // res.final carries the held-back line } for _, c := range cases { 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) { setupAuditTestDB(t) uid := id.UserID("@exp-cmd-nochar:example") diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 6990fd2..ce9c161 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -389,8 +389,39 @@ const ( 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 +// 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 // advanceOnce step. preStream/intro/phases/final mirror the streamOrSend // 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. // // compact==true (background autopilot) auto-resolves elite rooms via - // the same forward-sim engine used for exploration combat. Boss still - // pauses regardless — the boss is the run's climax beat and shouldn't - // be settled while the player isn't paying attention. + // the same forward-sim engine used for exploration combat. Long- + // expedition D3 extends the same path to boss rooms — gated by a + // 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 eliteAutoPhases []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()) } if sess == nil || sess.Status != CombatStatusWon { - if prev == RoomBoss || !compact { + if !compact { kind := "Elite" r := stopElite if prev == RoomBoss { @@ -514,10 +548,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) reason: r, }, nil } - // Compact-mode elite auto-resolve. - ai, ap, ao, aended, aerr := p.resolveCombatRoom(ctx.Sender, run, zone, true, true) + if prev == RoomBoss { + // 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 { - 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 { 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 // 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) { - 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 { - 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 } preHP, _ := dndHPSnapshot(userID) @@ -910,7 +976,10 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z hpDelta := preHP - postHP var ob strings.Builder label := monster.Name - if elite { + switch { + case isBoss: + label = "👑 Boss — " + monster.Name + case elite: label = "Elite " + monster.Name } 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(".") recordZoneKillForUser(userID, monster.ID) - applyRoomCombatThreatForUser(userID, elite) - if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { + applyRoomCombatThreatForUser(userID, elite || isBoss) + if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" { ob.WriteString(" ") ob.WriteString(drop) } diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go index 0f27953..4faab9e 100644 --- a/internal/plugin/expedition_autocamp.go +++ b/internal/plugin/expedition_autocamp.go @@ -334,6 +334,60 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav 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 // for minAutoCampDwell. Returns true when it broke a camp (so the // caller knows the walk should proceed this tick). Player-pitched camps diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 347178a..63adee3 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -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 // fork/combat/death/complete branches by checking r.reason. 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 { if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && fresh.Status == ExpeditionStatusActive {