mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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: "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: "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: "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"},
|
||||
@@ -354,6 +355,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(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") {
|
||||
return p.handleHarvestCmd(ctx, HarvestForage)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
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())
|
||||
}
|
||||
@@ -83,6 +85,7 @@ func expeditionHelpText() string {
|
||||
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")
|
||||
@@ -428,3 +431,143 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-nochar:example")
|
||||
|
||||
@@ -369,21 +369,69 @@ func roomGlyph(rt RoomType) string {
|
||||
|
||||
// ── 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,
|
||||
// then moves them to the next. Resolution branches on RoomType — combat
|
||||
// for Exploration/Elite/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.
|
||||
// then moves them to the next. Thin wrapper over advanceOnce + dispatch;
|
||||
// the expedition autopilot (expeditionCmdRun) reuses advanceOnce in a
|
||||
// loop. Resolution branches on RoomType — combat for Exploration/Elite/
|
||||
// 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 {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
@@ -399,16 +447,20 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
encID := encounterIDForRoom(prevIdx)
|
||||
sess, serr := getCombatSessionForEncounter(run.RunID, encID)
|
||||
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 {
|
||||
kind := "Elite"
|
||||
r := stopElite
|
||||
if prev == RoomBoss {
|
||||
kind = "Boss"
|
||||
r = stopBoss
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"**Room %d/%d — %s.** Type `!fight` to engage.",
|
||||
prevIdx+1, run.TotalRooms, kind))
|
||||
return advanceResult{
|
||||
final: fmt.Sprintf("**Room %d/%d — %s.** Type `!fight` to engage.",
|
||||
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.
|
||||
patrolIntro, patrolPhases, patrolOutcome, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
|
||||
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
|
||||
if patrolPhases != nil {
|
||||
@@ -432,7 +484,11 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
}
|
||||
if patrolEnded {
|
||||
// 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
|
||||
// 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.
|
||||
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone)
|
||||
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 {
|
||||
// Death (combat or otherwise). Stream phases if present, then the
|
||||
// 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,
|
||||
@@ -460,11 +522,11 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
// for 2+ edges, or complete the run when there are 0 outgoing edges.
|
||||
c, cerr := LoadDnDCharacter(ctx.Sender)
|
||||
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)
|
||||
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 {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||
@@ -484,7 +546,13 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
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 != "" {
|
||||
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(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,
|
||||
p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next))
|
||||
return advanceResult{
|
||||
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 —
|
||||
|
||||
Reference in New Issue
Block a user