Files
gogobee/internal/plugin/dnd_expedition_cmd.go
prosolis 14f9b3159f Expedition autopilot Phase 1: !explore / !expedition run
Adds an autopilot loop that walks rooms automatically until a
natural or threshold interrupt fires.

- New !explore (top-level alias) and !expedition run subcommand
  driven by expeditionCmdRun in dnd_expedition_cmd.go.
- advanceOnce extracted from zoneCmdAdvance so the autopilot and
  the manual !zone advance path share one room-step implementation.
- Stop reasons: fork, elite/boss doorway, death, complete,
  active-combat block, HP <= 30%, SU < 1 day's burn, 6-room cap.
- Trash combat continues to auto-resolve inside resolveCombatRoom;
  no changes there.
- TestAutopilotFooter_Reasons covers the pure stop-reason logic;
  preflight + walk tests skip without the prod DB, matching the
  existing setupAuditTestDB pattern.

Phase 2 (auto-harvest) and Phase 3 (real-time ticking) deferred.
2026-05-14 23:02:53 -07:00

574 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 23s-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
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
}
}
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)
}
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
default: // stopOK — hit the room cap
return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
}
}