diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index a2289a8..ded3187 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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|]", Category: "Games"}, {Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|]", 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) } diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 828ce61..02e5800 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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 ` 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) + } +} diff --git a/internal/plugin/dnd_expedition_cmd_test.go b/internal/plugin/dnd_expedition_cmd_test.go index 101cad8..97d9883 100644 --- a/internal/plugin/dnd_expedition_cmd_test.go +++ b/internal/plugin/dnd_expedition_cmd_test.go @@ -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") diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 16f7bd6..6d518df 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 `.") + return advanceResult{}, fmt.Errorf("No active zone run. Use `!zone enter `.") } 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 —