mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user