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

@@ -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")