mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Each walked room gets one auto-harvest pass across every applicable
action (forage, scavenge, mine, fish in water zones, plus the class-
restricted essence/commune). Class/kill/event gates are inherited
from the manual harvest path.
Stop rules:
- Rare+ nodes are NOT auto-attempted; they pause the walk via
stopRareNode so the player spends the attempt deliberately.
- Standard/Elite/Patrol interrupts hard-stop the walk (stopEnded on
death, new stopHarvestCombat on survive).
- Noise interrupts apply threat+2 and continue, matching manual
!harvest behavior.
Display:
- Per-room compact footer ("+2 Scrap Iron, +1 Shadow Herb · 1 fail")
streamed inline as autopilot walks.
- Walk-end cumulative tally appended to whatever final block fires.
No SU surcharge — parity with manual !harvest.
Pure helpers unit-tested (rarity classification, footer rendering,
walk tally sort order, rare-pending dedup). Full autoHarvestRoom
path needs prod DB and follows the existing setupAuditTestDB gating
pattern.
640 lines
23 KiB
Go
640 lines
23 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"gogobee/internal/flavor"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// !expedition — Phase 12 E1c. The command surface for the multi-day
|
||
// expedition system landed in E1a/E1b. This file is presentation +
|
||
// dispatch only; data plumbing lives in dnd_expedition.go and supply
|
||
// math in dnd_expedition_supplies.go.
|
||
//
|
||
// Subcommands:
|
||
//
|
||
// !expedition → help (or status if active)
|
||
// !expedition list → zones available at player level
|
||
// !expedition start <zone> [Ns] [Md] → outfit & start; default 1 standard pack
|
||
// !expedition status → daily briefing-style block (§12.1)
|
||
// !expedition log → last 5 expedition log entries
|
||
// !expedition abandon → end the expedition (no rewards)
|
||
// !expedition help → help text
|
||
//
|
||
// Day cycle (06:00/21:00 cron) lands in E1d. Camp commands (!camp) land
|
||
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.
|
||
|
||
func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
c, err := LoadDnDCharacter(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
||
}
|
||
if c == nil || c.PendingSetup {
|
||
return p.SendDM(ctx.Sender,
|
||
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
|
||
}
|
||
|
||
args = strings.TrimSpace(args)
|
||
sub, rest := splitFirstWord(args)
|
||
switch strings.ToLower(sub) {
|
||
case "":
|
||
// If active, show status; otherwise help.
|
||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||
return p.expeditionCmdStatus(ctx)
|
||
}
|
||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||
case "help", "?":
|
||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||
case "list", "ls":
|
||
return p.expeditionCmdList(ctx, c)
|
||
case "start", "begin", "go":
|
||
return p.expeditionCmdStart(ctx, c, rest)
|
||
case "status", "info":
|
||
return p.expeditionCmdStatus(ctx)
|
||
case "log", "history":
|
||
return p.expeditionCmdLog(ctx)
|
||
case "abandon", "quit":
|
||
return p.expeditionCmdAbandon(ctx)
|
||
case "extract":
|
||
return p.handleExtractCmd(ctx, "")
|
||
case "resume":
|
||
return p.handleResumeCmd(ctx, rest)
|
||
case "map", "m":
|
||
return p.handleExpeditionMapCmd(ctx, "")
|
||
case "run", "explore", "advance":
|
||
return p.expeditionCmdRun(ctx)
|
||
default:
|
||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||
}
|
||
}
|
||
|
||
func expeditionHelpText() string {
|
||
var b strings.Builder
|
||
b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
|
||
b.WriteString("`!expedition list` — show zones available at your level\n")
|
||
b.WriteString("`!expedition start <zone> [Ns] [Md]` — outfit & begin\n")
|
||
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n")
|
||
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n")
|
||
b.WriteString(" default: `1s`\n")
|
||
b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\n")
|
||
b.WriteString("`!expedition status` — current expedition snapshot\n")
|
||
b.WriteString("`!expedition log` — last 5 log entries\n")
|
||
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n")
|
||
b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n")
|
||
b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n")
|
||
b.WriteString("`!map` — region/room ASCII map")
|
||
return b.String()
|
||
}
|
||
|
||
// ── list ────────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter) error {
|
||
zones := zonesForLevel(c.Level)
|
||
if len(zones) == 0 {
|
||
return p.SendDM(ctx.Sender, "No zones available at your level. (This shouldn't happen — file a bug.)")
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("**Expeditions available at L%d** (you can enter zones up to 2 tiers above your current tier):\n\n", c.Level))
|
||
for i, z := range zones {
|
||
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d–%d_ `!expedition start %s`\n",
|
||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
|
||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||
}
|
||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||
zone := zoneOrFallback(exp.ZoneID)
|
||
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. Use `!expedition status` or `!expedition abandon`._",
|
||
zone.Display, exp.CurrentDay))
|
||
}
|
||
return p.SendDM(ctx.Sender, b.String())
|
||
}
|
||
|
||
// ── start ───────────────────────────────────────────────────────────────────
|
||
|
||
// parseSupplyArgs reads a token stream like "2s 1d" into a SupplyPurchase.
|
||
// Tokens are case-insensitive; suffix s|standard or d|deluxe. Bare integer
|
||
// is interpreted as standard packs. Default (empty) is one standard pack.
|
||
func parseSupplyArgs(rest string) (SupplyPurchase, error) {
|
||
p := SupplyPurchase{}
|
||
if strings.TrimSpace(rest) == "" {
|
||
p.StandardPacks = 1
|
||
return p, nil
|
||
}
|
||
for _, tok := range strings.Fields(rest) {
|
||
t := strings.ToLower(strings.TrimSpace(tok))
|
||
if t == "" {
|
||
continue
|
||
}
|
||
var (
|
||
numPart string
|
||
suf string
|
||
)
|
||
// Find first non-digit position.
|
||
i := 0
|
||
for i < len(t) && t[i] >= '0' && t[i] <= '9' {
|
||
i++
|
||
}
|
||
numPart = t[:i]
|
||
suf = t[i:]
|
||
if numPart == "" {
|
||
return p, fmt.Errorf("can't parse pack token %q", tok)
|
||
}
|
||
n, err := strconv.Atoi(numPart)
|
||
if err != nil {
|
||
return p, fmt.Errorf("can't parse pack count in %q", tok)
|
||
}
|
||
switch suf {
|
||
case "", "s", "std", "standard":
|
||
p.StandardPacks += n
|
||
case "d", "dlx", "deluxe":
|
||
p.DeluxePacks += n
|
||
default:
|
||
return p, fmt.Errorf("unknown pack suffix in %q (use s or d)", tok)
|
||
}
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
|
||
if rest == "" {
|
||
return p.SendDM(ctx.Sender,
|
||
"`!expedition start <zone> [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).")
|
||
}
|
||
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"🛌 You're still resting — %s remaining. Pack up after.",
|
||
formatRespecDuration(remaining)))
|
||
}
|
||
zoneTok, packTok := splitFirstWord(rest)
|
||
available := zonesForLevel(c.Level)
|
||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender,
|
||
"Unknown zone for your level. Try `!expedition list`.")
|
||
}
|
||
purchase, err := parseSupplyArgs(packTok)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
||
}
|
||
if err := purchase.Validate(); err != nil {
|
||
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
||
}
|
||
cost := float64(purchase.Cost())
|
||
if p.euro == nil {
|
||
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
||
}
|
||
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
|
||
int(cost), balance))
|
||
}
|
||
|
||
// Reject if any expedition or zone run already active.
|
||
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
|
||
zone, _ := getZone(existing.ZoneID)
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||
zone.Display, existing.CurrentDay))
|
||
}
|
||
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
|
||
zone, _ := getZone(existing.ZoneID)
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
|
||
zone.Display))
|
||
}
|
||
|
||
zone, _ := getZone(zoneID)
|
||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||
// snapshot without inflating the coin cost.
|
||
suppliesPurchase := purchase
|
||
if isHol, _ := isHolidayToday(); isHol {
|
||
suppliesPurchase.StandardPacks++
|
||
}
|
||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||
|
||
// Debit coins; bail on debit failure (race / cap).
|
||
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
|
||
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
||
}
|
||
|
||
exp, err := startExpedition(ctx.Sender, zoneID, "", supplies)
|
||
if err != nil {
|
||
// Refund on persistence failure.
|
||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
|
||
return p.SendDM(ctx.Sender, "Couldn't start expedition: "+err.Error())
|
||
}
|
||
|
||
// R2 — auto-spawn the DungeonRun for the starting region. Per-room
|
||
// harvest depends on this; the existing zone-run !advance/!retreat
|
||
// commands now operate on this run while the expedition is active.
|
||
if _, err := ensureRegionRun(exp, c.Level); err != nil {
|
||
// Refund and tear the expedition row back down — without a
|
||
// linked run, harvest and rooms can't function.
|
||
_ = abandonExpedition(ctx.Sender)
|
||
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund (run-spawn failed)")
|
||
return p.SendDM(ctx.Sender, "Couldn't outfit the first region: "+err.Error())
|
||
}
|
||
|
||
// Log the start with prewritten flavor.
|
||
startLine := flavor.Pick(flavor.ExpeditionStart)
|
||
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
|
||
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
|
||
b.WriteString("_" + zone.Hook + "_\n\n")
|
||
b.WriteString(fmt.Sprintf("**Outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
|
||
supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
|
||
b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — that's roughly **%d days** of provisions.\n\n",
|
||
supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
|
||
if startLine != "" {
|
||
b.WriteString(startLine)
|
||
b.WriteString("\n\n")
|
||
}
|
||
b.WriteString("Use `!expedition status` for the daily briefing format. Day 1 begins now.")
|
||
return p.SendDM(ctx.Sender, b.String())
|
||
}
|
||
|
||
func estimateDays(maxSU, dailyBurn float32) int {
|
||
if dailyBurn <= 0 {
|
||
return 0
|
||
}
|
||
return int(math.Floor(float64(maxSU / dailyBurn)))
|
||
}
|
||
|
||
// ── status ──────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
||
exp, err := getActiveExpedition(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||
}
|
||
if exp == nil {
|
||
return p.SendDM(ctx.Sender,
|
||
"No active expedition. Use `!expedition list` then `!expedition start <zone>`.")
|
||
}
|
||
zone, _ := getZone(exp.ZoneID)
|
||
c, _ := LoadDnDCharacter(ctx.Sender)
|
||
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d**\n\n", exp.CurrentDay))
|
||
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
|
||
if r, ok := CurrentRegion(exp); ok {
|
||
cleared := IsRegionCleared(exp, r.ID)
|
||
marker := ""
|
||
if cleared {
|
||
marker = " ✓"
|
||
}
|
||
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
|
||
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
|
||
}
|
||
if c != nil {
|
||
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
||
}
|
||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
||
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp),
|
||
estimateDays(exp.Supplies.Current, currentBurn(exp))))
|
||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||
if exp.TemporalStack != 0 {
|
||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
||
}
|
||
if exp.Camp != nil && exp.Camp.Active {
|
||
b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
|
||
}
|
||
state := supplyDepletion(exp.Supplies)
|
||
if state != SupplyNormal {
|
||
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
||
depletionLabel(state), supplyRollModifier(state)))
|
||
}
|
||
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
||
exp.StartDate.Format("2006-01-02 15:04"),
|
||
exp.LastActivity.Format("2006-01-02 15:04")))
|
||
return p.SendDM(ctx.Sender, b.String())
|
||
}
|
||
|
||
func currentBurn(exp *Expedition) float32 {
|
||
burn := exp.Supplies.DailyBurn
|
||
if exp.SiegeMode {
|
||
// §8.3: siege doubles supply burn outright, overriding HarshMod
|
||
// (which can be < 2 at low tiers).
|
||
mult := exp.Supplies.HarshMod
|
||
if mult < 2 {
|
||
mult = 2
|
||
}
|
||
return burn * mult
|
||
}
|
||
if exp.ThreatLevel > 60 {
|
||
// Harsh conditions: §4.1.
|
||
mult := exp.Supplies.HarshMod
|
||
if mult <= 0 {
|
||
mult = 1
|
||
}
|
||
burn *= mult
|
||
}
|
||
return burn
|
||
}
|
||
|
||
func threatThresholdLabel(level int, siege bool) string {
|
||
if siege {
|
||
return "Siege"
|
||
}
|
||
switch {
|
||
case level >= 81:
|
||
return "Siege"
|
||
case level >= 61:
|
||
return "Hostile"
|
||
case level >= 41:
|
||
return "Alert"
|
||
case level >= 21:
|
||
return "Stirring"
|
||
default:
|
||
return "Quiet"
|
||
}
|
||
}
|
||
|
||
func depletionLabel(s SupplyDepletionState) string {
|
||
switch s {
|
||
case SupplyRationing:
|
||
return "Rationing"
|
||
case SupplySevereRationing:
|
||
return "Severe rationing"
|
||
case SupplyStarvation:
|
||
return "Starvation"
|
||
}
|
||
return "Normal"
|
||
}
|
||
|
||
// ── log ─────────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
|
||
exp, err := getActiveExpedition(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||
}
|
||
if exp == nil {
|
||
return p.SendDM(ctx.Sender, "No active expedition.")
|
||
}
|
||
entries, err := recentExpeditionLog(exp.ID, 5)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't read log: "+err.Error())
|
||
}
|
||
if len(entries) == 0 {
|
||
return p.SendDM(ctx.Sender, "No log entries yet.")
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("📜 **Expedition log — last %d entries**\n\n", len(entries)))
|
||
for _, e := range entries {
|
||
b.WriteString(fmt.Sprintf("**Day %d** — _%s_ (%s)\n",
|
||
e.Day, e.Type, formatLogTimestamp(e.Timestamp)))
|
||
if e.Summary != "" {
|
||
b.WriteString(" " + e.Summary + "\n")
|
||
}
|
||
if e.Flavor != "" {
|
||
b.WriteString(" _" + e.Flavor + "_\n")
|
||
}
|
||
}
|
||
return p.SendDM(ctx.Sender, b.String())
|
||
}
|
||
|
||
func formatLogTimestamp(t time.Time) string {
|
||
return t.Format("2006-01-02 15:04")
|
||
}
|
||
|
||
// ── abandon ─────────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||
exp, err := getActiveExpedition(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||
}
|
||
if exp == nil {
|
||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
||
}
|
||
zone, _ := getZone(exp.ZoneID)
|
||
if err := abandonExpedition(ctx.Sender); err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||
}
|
||
_ = retireAllRegionRuns(exp)
|
||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||
zone.Display, exp.CurrentDay))
|
||
}
|
||
|
||
// helper: ensure we don't shadow id.UserID import in test harness.
|
||
var _ id.UserID
|
||
|
||
// ── run (autopilot) ─────────────────────────────────────────────────────────
|
||
|
||
// autopilotRoomCap bounds a single `!expedition run` invocation. Real-time
|
||
// background ticking is the planned long-game (see chat 2026-05-14); this
|
||
// cap keeps the foreground walk from monopolising the bot for an
|
||
// unbounded stretch and gives the player a natural "press to continue"
|
||
// breath between long runs.
|
||
const autopilotRoomCap = 6
|
||
|
||
// autopilotLowHPPct stops the walk when current HP drops at or below this
|
||
// fraction of max. 0.30 = "you're hurt enough that the next bad room
|
||
// could end the run; pause, heal, or commit."
|
||
const autopilotLowHPPct = 0.30
|
||
|
||
// expeditionCmdRun is the autopilot surface. It loops advanceOnce until a
|
||
// natural interrupt fires (fork, elite/boss doorway, death, complete) or
|
||
// an injected interrupt fires (low HP, low SU, room cap). Each iteration's
|
||
// staged narration is concatenated into a single 2–3s-paced stream so the
|
||
// player reads the full multi-room walk as one continuous beat. Trash
|
||
// combat already auto-resolves inside resolveCombatRoom; elite/boss
|
||
// doorways stop here so the player can choose !fight on their own terms.
|
||
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||
exp, err := getActiveExpedition(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||
}
|
||
if exp == nil {
|
||
return p.SendDM(ctx.Sender, "You're not on an expedition. `!expedition list` to pick one.")
|
||
}
|
||
if exp.RunID == "" {
|
||
return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh.")
|
||
}
|
||
|
||
var stream []string
|
||
var finalMsg string
|
||
rooms := 0
|
||
|
||
// Phase 2 — walk-wide auto-harvest tally. Per-room footers go into the
|
||
// stream as we go; the cumulative haul renders on the final block.
|
||
walkYields := map[string]int{}
|
||
walkNames := map[string]string{}
|
||
|
||
for i := 0; i < autopilotRoomCap; i++ {
|
||
// Pre-iteration interrupts: low HP / low SU. Skip on the first
|
||
// iteration so the player always gets at least one room out of
|
||
// `!expedition run` even if they limped in — autopilot stops on
|
||
// thresholds *between* rooms, not before the first one.
|
||
if i > 0 {
|
||
if msg, stop := autopilotPreflight(ctx.Sender, exp); stop {
|
||
finalMsg = msg
|
||
break
|
||
}
|
||
}
|
||
|
||
res, aerr := p.advanceOnce(ctx)
|
||
if aerr != nil {
|
||
return p.SendDM(ctx.Sender, aerr.Error())
|
||
}
|
||
|
||
// Roll this step's beats into the running stream.
|
||
stream = append(stream, res.preStream...)
|
||
if res.intro != "" {
|
||
stream = append(stream, res.intro)
|
||
}
|
||
stream = append(stream, res.phases...)
|
||
|
||
// 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 {
|
||
rooms++
|
||
}
|
||
|
||
if res.reason != stopOK {
|
||
footer := autopilotFooter(res.reason, rooms)
|
||
if footer != "" {
|
||
finalMsg = res.final + "\n\n" + footer
|
||
} else {
|
||
finalMsg = res.final
|
||
}
|
||
break
|
||
}
|
||
|
||
// Walked into the next room. Push this step's "✓ cleared / next
|
||
// room" block as a phase so the next iteration's intro lands
|
||
// after it, then loop. Refresh exp for the next preflight read
|
||
// (HP/SU may have moved during combat).
|
||
stream = append(stream, res.final)
|
||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
||
exp = fresh
|
||
}
|
||
|
||
// Phase 2 — auto-harvest the room we just walked into. Aggregates
|
||
// into walkYields/walkNames; per-room footer goes straight into
|
||
// the stream. Rare+ nodes and combat interrupts hard-stop the
|
||
// walk with dedicated stop reasons.
|
||
char, cerr := LoadDnDCharacter(ctx.Sender)
|
||
if cerr != nil || char == nil {
|
||
continue
|
||
}
|
||
hr, herr := p.autoHarvestRoom(ctx.Sender, exp, char)
|
||
if herr != nil {
|
||
continue
|
||
}
|
||
for k, v := range hr.Summary.Yields {
|
||
walkYields[k] += v
|
||
walkNames[k] = hr.Summary.Names[k]
|
||
}
|
||
if footer := renderAutoHarvestFooter(hr.Summary); footer != "" {
|
||
stream = append(stream, footer)
|
||
}
|
||
if hr.CombatNarr != "" {
|
||
// A harvest interrupt fired combat. The combat narration
|
||
// becomes the final block; reason classifies death vs survive.
|
||
reason := stopHarvestCombat
|
||
if hr.PlayerEnded {
|
||
reason = stopEnded
|
||
}
|
||
footer := autopilotFooter(reason, rooms)
|
||
finalMsg = hr.CombatNarr
|
||
if footer != "" {
|
||
finalMsg += "\n\n" + footer
|
||
}
|
||
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
|
||
finalMsg += "\n\n" + tally
|
||
}
|
||
break
|
||
}
|
||
if len(hr.RarePending) > 0 {
|
||
finalMsg = renderRarePendingFooter(hr.RarePending)
|
||
if tally := renderWalkTally(walkYields, walkNames); tally != "" {
|
||
finalMsg += "\n\n" + tally
|
||
}
|
||
break
|
||
}
|
||
|
||
// Refresh exp once more — auto-harvest may have bumped threat
|
||
// via noise interrupts.
|
||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
||
exp = fresh
|
||
}
|
||
}
|
||
|
||
if finalMsg == "" {
|
||
// Hit the room cap without a hard interrupt — synthesize a
|
||
// "press to continue" footer so the player knows autopilot
|
||
// stopped on its own clock, not because something needs them.
|
||
finalMsg = autopilotFooter(stopOK, rooms)
|
||
}
|
||
// Walk-wide haul tally — appended to whatever the final block is,
|
||
// unless a combat-interrupt branch already added it (death suppresses
|
||
// it; harvest-combat survival adds it inline).
|
||
if tally := renderWalkTally(walkYields, walkNames); tally != "" &&
|
||
!strings.Contains(finalMsg, "Walk haul:") {
|
||
finalMsg += "\n\n" + tally
|
||
}
|
||
return p.streamFlow(ctx.Sender, stream, finalMsg)
|
||
}
|
||
|
||
// autopilotPreflight checks the threshold-based interrupts that fire
|
||
// between rooms. Returns (footer, true) when autopilot should stop.
|
||
func autopilotPreflight(userID id.UserID, exp *Expedition) (string, bool) {
|
||
cur, max := dndHPSnapshot(userID)
|
||
if max > 0 && float64(cur) <= float64(max)*autopilotLowHPPct {
|
||
return fmt.Sprintf(
|
||
"⏸ **Autopilot paused — HP low** (%d/%d). `!camp` to rest, `!cast` healing, or `!expedition run` to push on.",
|
||
cur, max), true
|
||
}
|
||
if exp.Supplies.DailyBurn > 0 && exp.Supplies.Current < exp.Supplies.DailyBurn {
|
||
return fmt.Sprintf(
|
||
"⏸ **Autopilot paused — supplies low** (%.1f / %.1f SU, under one day). `!extract` to bail, `!forage`, or `!expedition run` to push on.",
|
||
exp.Supplies.Current, exp.Supplies.DailyBurn), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// autopilotFooter renders the closing line for an autopilot walk. The
|
||
// rooms-walked tally is informational; reason controls the verb and the
|
||
// suggested next step.
|
||
func autopilotFooter(reason stopReason, rooms int) string {
|
||
roomsStr := "1 room"
|
||
if rooms != 1 {
|
||
roomsStr = fmt.Sprintf("%d rooms", rooms)
|
||
}
|
||
switch reason {
|
||
case stopFork:
|
||
return fmt.Sprintf("⏸ **Autopilot paused at a fork** (after %s). `!zone go <n>` to choose, then `!expedition run` to keep walking.", roomsStr)
|
||
case stopElite:
|
||
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 stopEnded:
|
||
return "" // death narration is the final; no footer
|
||
case stopComplete:
|
||
return "" // run-complete block is the final; no footer
|
||
case stopBlocked:
|
||
return "" // "finish your fight first" is the final; no footer
|
||
case stopRareNode:
|
||
return "" // rare-pending footer is the final; no extra suffix
|
||
case stopHarvestCombat:
|
||
return fmt.Sprintf("⏸ **Autopilot paused — interrupted while gathering** (after %s). Reassess and `!expedition run` to continue.", roomsStr)
|
||
default: // stopOK — hit the room cap
|
||
return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
|
||
}
|
||
}
|