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.
This commit is contained in:
prosolis
2026-05-14 23:02:53 -07:00
parent c2fdc63b51
commit 14f9b3159f
4 changed files with 381 additions and 21 deletions

View File

@@ -170,6 +170,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "commune", Description: "Commune with spiritual energy in your current expedition region (Cleric primary)", Usage: "!commune", Category: "Games"}, {Name: "commune", Description: "Commune with spiritual energy in your current expedition region (Cleric primary)", Usage: "!commune", Category: "Games"},
{Name: "fish", Description: "Fish in your current expedition region (DEX / Sleight of Hand; water zones only)", Usage: "!fish", Category: "Games"}, {Name: "fish", Description: "Fish in your current expedition region (DEX / Sleight of Hand; water zones only)", Usage: "!fish", Category: "Games"},
{Name: "resources", Description: "List harvestable resources in your current expedition region", Usage: "!resources", Category: "Games"}, {Name: "resources", Description: "List harvestable resources in your current expedition region", Usage: "!resources", Category: "Games"},
{Name: "explore", Description: "Autopilot: walk through expedition rooms until something needs your attention (fork, elite/boss, low HP/supplies)", Usage: "!explore", Category: "Games"},
{Name: "sell", Description: "Sell harvested materials/fish/items to Thom Krooke (post-expedition; CHA Persuasion DC 17 = +15%)", Usage: "!sell [list|all|<item>]", Category: "Games"}, {Name: "sell", Description: "Sell harvested materials/fish/items to Thom Krooke (post-expedition; CHA Persuasion DC 17 = +15%)", Usage: "!sell [list|all|<item>]", Category: "Games"},
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"}, {Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for an undiscovered recipe (INT/Arcana DC 15)", Usage: "!lore", Category: "Games"}, {Name: "lore", Description: "Dig through Thom Krooke's lore stacks for an undiscovered recipe (INT/Arcana DC 15)", Usage: "!lore", Category: "Games"},
@@ -354,6 +355,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "map") { if p.IsCommand(ctx.Body, "map") {
return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map")) return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map"))
} }
if p.IsCommand(ctx.Body, "explore") {
return p.expeditionCmdRun(ctx)
}
if p.IsCommand(ctx.Body, "forage") { if p.IsCommand(ctx.Body, "forage") {
return p.handleHarvestCmd(ctx, HarvestForage) return p.handleHarvestCmd(ctx, HarvestForage)
} }

View File

@@ -70,6 +70,8 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
return p.handleResumeCmd(ctx, rest) return p.handleResumeCmd(ctx, rest)
case "map", "m": case "map", "m":
return p.handleExpeditionMapCmd(ctx, "") return p.handleExpeditionMapCmd(ctx, "")
case "run", "explore", "advance":
return p.expeditionCmdRun(ctx)
default: default:
return p.SendDM(ctx.Sender, expeditionHelpText()) return p.SendDM(ctx.Sender, expeditionHelpText())
} }
@@ -83,6 +85,7 @@ func expeditionHelpText() string {
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\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(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n")
b.WriteString(" default: `1s`\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 status` — current expedition snapshot\n")
b.WriteString("`!expedition log` — last 5 log entries\n") b.WriteString("`!expedition log` — last 5 log entries\n")
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n") b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n")
@@ -428,3 +431,143 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
// helper: ensure we don't shadow id.UserID import in test harness. // helper: ensure we don't shadow id.UserID import in test harness.
var _ id.UserID 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)
}
}

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"strings"
"testing" "testing"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -189,6 +190,139 @@ func TestExpeditionCmd_StartInsufficientCoins(t *testing.T) {
} }
} }
// TestExpeditionCmd_RunNoExpedition: !expedition run with no active
// expedition is a friendly no-op, not an error.
func TestExpeditionCmd_RunNoExpedition(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@exp-cmd-run-noexp:example")
expeditionCmdTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "run"); err != nil {
t.Fatalf("run with no expedition: %v", err)
}
}
// TestExpeditionCmd_RunWalksRooms: starting an expedition then calling
// `!expedition run` advances the underlying zone-run by at least one
// room (and stops cleanly at a natural interrupt — fork, elite, boss,
// complete, or the autopilot room cap).
func TestExpeditionCmd_RunWalksRooms(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@exp-cmd-run-walk:example")
expeditionCmdTestCharacter(t, uid, 3)
defer cleanupExpeditions(uid)
euro := &EuroPlugin{}
euro.ensureBalance(uid)
euro.Credit(uid, 200, "test setup")
p := &AdventurePlugin{euro: euro}
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil {
t.Fatal(err)
}
exp, _ := getActiveExpedition(uid)
if exp == nil || exp.RunID == "" {
t.Fatal("expected active expedition with run")
}
startRun, _ := getZoneRun(exp.RunID)
if startRun == nil {
t.Fatal("expected zone run for expedition")
}
startRoom := startRun.CurrentRoom
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "run"); err != nil {
t.Fatalf("run: %v", err)
}
// After autopilot, the run should have advanced. Either the room
// pointer moved, or the run completed (TotalRooms reached). For a
// fresh L3 run at GoblinWarrens we expect at least one room walked
// — the entry room is narration-only and never blocks.
endRun, _ := getZoneRun(exp.RunID)
if endRun == nil {
// Run could legitimately complete and be cleared; that's also fine.
return
}
if endRun.CurrentRoom <= startRoom {
t.Errorf("autopilot did not advance: room %d → %d", startRoom, endRun.CurrentRoom)
}
}
// TestAutopilotPreflight_LowHP: HP at or below the threshold trips the
// preflight stop with the low-HP footer.
func TestAutopilotPreflight_LowHP(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@exp-autopilot-lowhp:example")
expeditionCmdTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = 5 // 5/20 = 25%, below 30% threshold
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
exp := &Expedition{
Supplies: ExpeditionSupplies{Current: 10, DailyBurn: 1},
}
msg, stop := autopilotPreflight(uid, exp)
if !stop {
t.Fatal("expected low-HP stop")
}
if !strings.Contains(msg, "HP low") {
t.Errorf("expected HP-low footer, got %q", msg)
}
}
// TestAutopilotPreflight_LowSU: supplies under one day's burn trips.
func TestAutopilotPreflight_LowSU(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@exp-autopilot-lowsu:example")
expeditionCmdTestCharacter(t, uid, 1)
defer cleanupExpeditions(uid)
exp := &Expedition{
Supplies: ExpeditionSupplies{Current: 0.5, DailyBurn: 1.5},
}
msg, stop := autopilotPreflight(uid, exp)
if !stop {
t.Fatal("expected low-SU stop")
}
if !strings.Contains(msg, "supplies low") {
t.Errorf("expected SU-low footer, got %q", msg)
}
}
// TestAutopilotFooter_Reasons: each stop reason gets a distinct,
// non-empty footer (or empty for stops where the final block already
// ends the message — death, complete, blocked).
func TestAutopilotFooter_Reasons(t *testing.T) {
cases := []struct {
r stopReason
want string // substring; empty means footer must be empty
wantHas bool
}{
{stopFork, "fork", true},
{stopElite, "elite", true},
{stopBoss, "boss", true},
{stopOK, "stretch", true}, // hit room cap
{stopEnded, "", false},
{stopComplete, "", false},
{stopBlocked, "", false},
}
for _, c := range cases {
got := autopilotFooter(c.r, 3)
if c.wantHas {
if got == "" || !strings.Contains(got, c.want) {
t.Errorf("%v: got %q, want substring %q", c.r, got, c.want)
}
} else if got != "" {
t.Errorf("%v: expected empty footer, got %q", c.r, got)
}
}
}
func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) { func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) {
setupAuditTestDB(t) setupAuditTestDB(t)
uid := id.UserID("@exp-cmd-nochar:example") uid := id.UserID("@exp-cmd-nochar:example")

View File

@@ -369,21 +369,69 @@ func roomGlyph(rt RoomType) string {
// ── advance ───────────────────────────────────────────────────────────────── // ── advance ─────────────────────────────────────────────────────────────────
// stopReason classifies why a single advanceOnce step stopped. StopOK means
// the step walked into the next room cleanly and a loop caller may keep
// going. Everything else is an interrupt — the autopilot loop in
// expeditionCmdRun renders the result and stops; the one-shot
// zoneCmdAdvance treats them all the same (single dispatch).
type stopReason int
const (
stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance
)
// 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
// loop callers whether they may iterate again.
type advanceResult struct {
preStream []string
intro string
phases []string
final string
reason stopReason
}
// zoneCmdAdvance resolves the room the player is currently standing in, // zoneCmdAdvance resolves the room the player is currently standing in,
// then moves them to the next. Resolution branches on RoomType — combat // then moves them to the next. Thin wrapper over advanceOnce + dispatch;
// for Exploration/Elite/Boss, a trap nick for Trap, narration-only for // the expedition autopilot (expeditionCmdRun) reuses advanceOnce in a
// Entry. Player loss aborts the run with a mood penalty and player-death // loop. Resolution branches on RoomType — combat for Exploration/Elite/
// flavor; boss win drops the zone Loot table. // Boss, a trap nick for Trap, narration-only for Entry. Player loss
// aborts the run with a mood penalty and player-death flavor; boss win
// drops the zone Loot table.
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
res, err := p.advanceOnce(ctx)
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
}
return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
}
// advanceOnce runs the single-room advance pipeline and returns a
// structured result the caller can stream as-is or aggregate. Errors
// returned here are user-facing (already prefixed with the appropriate
// "Couldn't ..." context); callers should SendDM them verbatim. State
// mutations on run / character / combat sessions persist regardless of
// how the caller dispatches the narration.
func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return advanceResult{}, fmt.Errorf("Couldn't read run state: %s", err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.") return advanceResult{}, fmt.Errorf("No active zone run. Use `!zone enter <id>`.")
} }
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") return advanceResult{
final: "⚔️ Finish your fight first — `!attack` or `!flee`.",
reason: stopBlocked,
}, nil
} }
_ = applyMoodDecayIfStale(run) _ = applyMoodDecayIfStale(run)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
@@ -399,16 +447,20 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
encID := encounterIDForRoom(prevIdx) encID := encounterIDForRoom(prevIdx)
sess, serr := getCombatSessionForEncounter(run.RunID, encID) sess, serr := getCombatSessionForEncounter(run.RunID, encID)
if serr != nil { if serr != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+serr.Error()) return advanceResult{}, fmt.Errorf("Couldn't read combat state: %s", serr.Error())
} }
if sess == nil || sess.Status != CombatStatusWon { if sess == nil || sess.Status != CombatStatusWon {
kind := "Elite" kind := "Elite"
r := stopElite
if prev == RoomBoss { if prev == RoomBoss {
kind = "Boss" kind = "Boss"
r = stopBoss
} }
return p.SendDM(ctx.Sender, fmt.Sprintf( return advanceResult{
"**Room %d/%d — %s.** Type `!fight` to engage.", final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.",
prevIdx+1, run.TotalRooms, kind)) prevIdx+1, run.TotalRooms, kind),
reason: r,
}, nil
} }
} }
@@ -421,7 +473,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// room intro → room play-by-play → final, in one continuous beat. // room intro → room play-by-play → final, in one continuous beat.
patrolIntro, patrolPhases, patrolOutcome, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone) patrolIntro, patrolPhases, patrolOutcome, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
if perr != nil { if perr != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve patrol: "+perr.Error()) return advanceResult{}, fmt.Errorf("Couldn't resolve patrol: %s", perr.Error())
} }
var preStream []string var preStream []string
if patrolPhases != nil { if patrolPhases != nil {
@@ -432,7 +484,11 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
} }
if patrolEnded { if patrolEnded {
// Patrol dropped the player; run is over. // Patrol dropped the player; run is over.
return p.streamFlow(ctx.Sender, preStream, patrolOutcome) return advanceResult{
preStream: preStream,
final: patrolOutcome,
reason: stopEnded,
}, nil
} }
// Patrol survived (or didn't fire). If it fired, patrolOutcome becomes // Patrol survived (or didn't fire). If it fired, patrolOutcome becomes
// a streamed midpoint between the patrol fight and the room resolver. // a streamed midpoint between the patrol fight and the room resolver.
@@ -447,12 +503,18 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// SendDM as before. // SendDM as before.
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone) intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve room: "+err.Error()) return advanceResult{}, fmt.Errorf("Couldn't resolve room: %s", err.Error())
} }
if ended { if ended {
// Death (combat or otherwise). Stream phases if present, then the // Death (combat or otherwise). Stream phases if present, then the
// death narration as the final message. // death narration as the final message.
return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome) return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: outcome,
reason: stopEnded,
}, nil
} }
// Graph-mode advance (G9a — only mode now): clear the current room, // Graph-mode advance (G9a — only mode now): clear the current room,
@@ -460,11 +522,11 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// for 2+ edges, or complete the run when there are 0 outgoing edges. // for 2+ edges, or complete the run when there are 0 outgoing edges.
c, cerr := LoadDnDCharacter(ctx.Sender) c, cerr := LoadDnDCharacter(ctx.Sender)
if cerr != nil { if cerr != nil {
return p.SendDM(ctx.Sender, "Couldn't load character: "+cerr.Error()) return advanceResult{}, fmt.Errorf("Couldn't load character: %s", cerr.Error())
} }
next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c) next, forkMsg, complete, gerr := p.advanceTransitionGraph(run, zone, c)
if gerr != nil { if gerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+gerr.Error()) return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error())
} }
if complete { if complete {
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
@@ -484,7 +546,13 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
b.WriteString("• " + id + "\n") b.WriteString("• " + id + "\n")
} }
} }
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopComplete,
}, nil
} }
if forkMsg != "" { if forkMsg != "" {
var b strings.Builder var b strings.Builder
@@ -494,10 +562,21 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
} }
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
b.WriteString(forkMsg) b.WriteString(forkMsg)
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String()) return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopFork,
}, nil
} }
return p.streamOrSend(ctx.Sender, preStream, intro, phases, return advanceResult{
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)) preStream: preStream,
intro: intro,
phases: phases,
final: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
reason: stopOK,
}, nil
} }
// formatNextRoomMessage builds the "✓ Cleared room X. ... Room Y/N — // formatNextRoomMessage builds the "✓ Cleared room X. ... Room Y/N —