mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Expedition autopilot Phase 2: auto-harvest on room entry
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.
This commit is contained in:
292
internal/plugin/dnd_expedition_autopilot_harvest.go
Normal file
292
internal/plugin/dnd_expedition_autopilot_harvest.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 2 of expedition autopilot — auto-harvest on room entry.
|
||||||
|
//
|
||||||
|
// Design (settled 2026-05-14):
|
||||||
|
// - All applicable actions auto-run on every walked room. Class- and
|
||||||
|
// kill-gated nodes filter themselves out via the existing per-resource
|
||||||
|
// restrictions.
|
||||||
|
// - Rare+ nodes are NOT auto-attempted. If any sit available in the
|
||||||
|
// room, autopilot stops with stopRareNode so the player can spend the
|
||||||
|
// attempt deliberately.
|
||||||
|
// - One attempt per eligible node per visit. A failed roll doesn't
|
||||||
|
// retry; it logs the fail and moves on. Charges only decrement on
|
||||||
|
// non-Nothing outcomes (mirrors handleHarvestCmd).
|
||||||
|
// - Noise interrupts apply threat++ and continue (parity with manual).
|
||||||
|
// - Standard/Elite/Patrol combat interrupts hard-stop the walk:
|
||||||
|
// stopEnded on death, stopHarvestCombat otherwise.
|
||||||
|
// - No SU surcharge — manual !harvest is free; autopilot matches.
|
||||||
|
// - Per-room footer ("+2 Iron, +1 Sage; 1 fail") + a walk-end tally
|
||||||
|
// across all rooms.
|
||||||
|
|
||||||
|
// autoHarvestSummary holds the aggregated outcome for one room's
|
||||||
|
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
||||||
|
// also folds it into a walk-wide tally for the closing block.
|
||||||
|
type autoHarvestSummary struct {
|
||||||
|
Yields map[string]int // resourceID -> total qty granted
|
||||||
|
Names map[string]string // resourceID -> display name (capture once)
|
||||||
|
Fails int
|
||||||
|
NoiseInts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoHarvestResult is what autoHarvestRoom returns to the autopilot loop.
|
||||||
|
type autoHarvestResult struct {
|
||||||
|
Summary autoHarvestSummary
|
||||||
|
RarePending []ZoneResource // non-empty = at least one Rare+ node sits in the room
|
||||||
|
CombatNarr string // non-empty = a combat interrupt fired during the pass
|
||||||
|
PlayerEnded bool // true = combat killed the player
|
||||||
|
}
|
||||||
|
|
||||||
|
// allHarvestActions lists every action autopilot will try per room. Order
|
||||||
|
// matters only for footer readability — class- and gate-restricted nodes
|
||||||
|
// drop out via the existing filters.
|
||||||
|
var allHarvestActions = []HarvestAction{
|
||||||
|
HarvestForage,
|
||||||
|
HarvestScavenge,
|
||||||
|
HarvestMine,
|
||||||
|
HarvestFish,
|
||||||
|
HarvestEssence,
|
||||||
|
HarvestCommune,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRarePlus reports whether a resource's rarity should pause autopilot.
|
||||||
|
// Common/Uncommon are auto-attempted; Rare and above stop the walk.
|
||||||
|
func isRarePlus(r DnDRarity) bool {
|
||||||
|
switch r {
|
||||||
|
case RarityRare, RarityEpic, RarityVeryRare, RarityLegendary:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoHarvestRoom runs a single auto-harvest pass on the player's current
|
||||||
|
// room. Called by expeditionCmdRun between walked rooms. Does not advance
|
||||||
|
// the room; only resolves nodes already at the player's feet.
|
||||||
|
func (p *AdventurePlugin) autoHarvestRoom(
|
||||||
|
userID id.UserID, exp *Expedition, char *DnDCharacter,
|
||||||
|
) (autoHarvestResult, error) {
|
||||||
|
res := autoHarvestResult{
|
||||||
|
Summary: autoHarvestSummary{
|
||||||
|
Yields: map[string]int{},
|
||||||
|
Names: map[string]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if exp == nil || exp.RunID == "" {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
run, err := getZoneRun(exp.RunID)
|
||||||
|
if err != nil || run == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if !currentRoomCleared(run) {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID := harvestNodeIDFor(run)
|
||||||
|
nodes := loadHarvestNodes(exp, nodeID)
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, _ := getZone(exp.ZoneID)
|
||||||
|
persistNodes := false
|
||||||
|
|
||||||
|
for _, action := range allHarvestActions {
|
||||||
|
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// One pass per action. Iterate every eligible node (not just the
|
||||||
|
// lowest-DC one) so a room with two foragables gets two attempts.
|
||||||
|
for i := range nodes {
|
||||||
|
n := &nodes[i]
|
||||||
|
if n.CurrentCharges <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rdef, ok := FindZoneResource(exp.ZoneID, n.ResourceID)
|
||||||
|
if !ok || rdef.Action != action {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rdef.RequiresKill != "" && !regionKillsContains(exp, rdef.RequiresKill) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rdef.ConditionalEvent != "" && !regionEventActive(exp, rdef.ConditionalEvent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRarePlus(rdef.Rarity) {
|
||||||
|
// Defer the decision to the player; don't consume the attempt.
|
||||||
|
res.RarePending = append(res.RarePending, rdef)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interrupt roll, same model as handleHarvestCmd.
|
||||||
|
interrupt, intTotal := resolveCombatInterrupt(
|
||||||
|
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
||||||
|
switch interrupt {
|
||||||
|
case InterruptNoise:
|
||||||
|
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||||
|
res.Summary.NoiseInts++
|
||||||
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||||
|
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
||||||
|
// fall through to the d20 roll, same as manual harvest.
|
||||||
|
case InterruptStandard, InterruptElite, InterruptPatrol:
|
||||||
|
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
||||||
|
res.CombatNarr = body
|
||||||
|
res.PlayerEnded = ended
|
||||||
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||||
|
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
|
||||||
|
if persistNodes {
|
||||||
|
_ = saveHarvestNodes(exp, nodeID, nodes)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the harvest attempt.
|
||||||
|
roll := rand.IntN(20) + 1
|
||||||
|
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||||||
|
if action == HarvestFish {
|
||||||
|
if adv, _ := loadAdvCharacter(userID); adv != nil {
|
||||||
|
mod += fishingSkillBonus(adv.FishingSkill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := roll + mod
|
||||||
|
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
|
||||||
|
effectiveDC := rdef.DC + dcDelta
|
||||||
|
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||||
|
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||||
|
|
||||||
|
var grantedQty int
|
||||||
|
switch outcome {
|
||||||
|
case OutcomeNothing:
|
||||||
|
res.Summary.Fails++
|
||||||
|
case OutcomeCommon:
|
||||||
|
grantedQty = 1
|
||||||
|
case OutcomeStandard:
|
||||||
|
grantedQty = rand.IntN(3) + 1
|
||||||
|
case OutcomeRich:
|
||||||
|
grantedQty = rand.IntN(3) + 1
|
||||||
|
if bonus := pickRichBonus(exp.ZoneID, rdef.ID); bonus != nil {
|
||||||
|
_ = grantHarvestYield(userID, *bonus, 1)
|
||||||
|
res.Summary.Yields[bonus.ID] += 1
|
||||||
|
res.Summary.Names[bonus.ID] = bonus.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if grantedQty > 0 {
|
||||||
|
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||||||
|
res.Summary.Yields[rdef.ID] += grantedQty
|
||||||
|
res.Summary.Names[rdef.ID] = rdef.Name
|
||||||
|
n.CurrentCharges--
|
||||||
|
persistNodes = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
|
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
||||||
|
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if persistNodes {
|
||||||
|
if err := saveHarvestNodes(exp, nodeID, nodes); err != nil {
|
||||||
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
|
fmt.Sprintf("autopilot persistence error: %v", err), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAutoHarvestFooter formats one room's pass as a single compact
|
||||||
|
// line: "_+2 Scrap Iron, +1 Shadow Herb · 1 fail · 1 close call._".
|
||||||
|
// Empty string means there's nothing worth showing.
|
||||||
|
func renderAutoHarvestFooter(s autoHarvestSummary) string {
|
||||||
|
if len(s.Yields) == 0 && s.Fails == 0 && s.NoiseInts == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := []string{}
|
||||||
|
// Stable ordering — sort yield keys so test output is deterministic
|
||||||
|
// and the footer reads consistently across rooms.
|
||||||
|
keys := make([]string, 0, len(s.Yields))
|
||||||
|
for k := range s.Yields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("+%d %s", s.Yields[k], s.Names[k]))
|
||||||
|
}
|
||||||
|
if s.Fails > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d fail", s.Fails))
|
||||||
|
}
|
||||||
|
if s.NoiseInts > 0 {
|
||||||
|
noun := "close call"
|
||||||
|
if s.NoiseInts > 1 {
|
||||||
|
noun = "close calls"
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%d %s", s.NoiseInts, noun))
|
||||||
|
}
|
||||||
|
return "_🎒 " + strings.Join(parts, " · ") + "._"
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWalkTally formats the cross-room cumulative haul for the walk's
|
||||||
|
// final block. Empty string when nothing was gathered.
|
||||||
|
func renderWalkTally(yields map[string]int, names map[string]string) string {
|
||||||
|
if len(yields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(yields))
|
||||||
|
for k := range yields {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("**Walk haul:** ")
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(", ")
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("%d %s", yields[k], names[k]))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderRarePendingFooter announces which Rare+ nodes triggered the
|
||||||
|
// autopilot pause. Used by the stop reason footer in expeditionCmdRun.
|
||||||
|
func renderRarePendingFooter(pending []ZoneResource) string {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// De-dup by ID (a single resource can have multiple charges across
|
||||||
|
// nodes; we want one mention).
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var names []string
|
||||||
|
for _, r := range pending {
|
||||||
|
if seen[r.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.ID] = true
|
||||||
|
names = append(names, fmt.Sprintf("**%s** (%s)", r.Name, string(r.Rarity)))
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
verb := "is"
|
||||||
|
if len(names) > 1 {
|
||||||
|
verb = "are"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("⏸ **Autopilot paused — rare yield nearby.** %s %s sitting in this room. `!%s` to take the shot, or `!expedition run` to push past.",
|
||||||
|
strings.Join(names, ", "), verb,
|
||||||
|
// suggest the action of the first pending resource as a hint.
|
||||||
|
string(pending[0].Action))
|
||||||
|
}
|
||||||
110
internal/plugin/dnd_expedition_autopilot_harvest_test.go
Normal file
110
internal/plugin/dnd_expedition_autopilot_harvest_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pure-logic tests for the Phase 2 auto-harvest helpers. The full
|
||||||
|
// autoHarvestRoom path needs the prod DB (loads zone runs / expeditions /
|
||||||
|
// region state) and lives behind the same setupAuditTestDB gate as the
|
||||||
|
// other expedition tests.
|
||||||
|
|
||||||
|
func TestIsRarePlus(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in DnDRarity
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{RarityCommon, false},
|
||||||
|
{RarityUncommon, false},
|
||||||
|
{RarityRare, true},
|
||||||
|
{RarityEpic, true},
|
||||||
|
{RarityVeryRare, true},
|
||||||
|
{RarityLegendary, true},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := isRarePlus(c.in); got != c.want {
|
||||||
|
t.Errorf("isRarePlus(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderAutoHarvestFooter_EmptyIsEmpty(t *testing.T) {
|
||||||
|
s := autoHarvestSummary{Yields: map[string]int{}, Names: map[string]string{}}
|
||||||
|
if got := renderAutoHarvestFooter(s); got != "" {
|
||||||
|
t.Errorf("expected empty footer for empty summary, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderAutoHarvestFooter_YieldsAndFailsAndNoise(t *testing.T) {
|
||||||
|
s := autoHarvestSummary{
|
||||||
|
Yields: map[string]int{"iron": 2, "herb": 1},
|
||||||
|
Names: map[string]string{"iron": "Scrap Iron", "herb": "Shadow Herb"},
|
||||||
|
Fails: 1,
|
||||||
|
NoiseInts: 2,
|
||||||
|
}
|
||||||
|
got := renderAutoHarvestFooter(s)
|
||||||
|
// Deterministic ordering: yield keys sorted alphabetically.
|
||||||
|
for _, want := range []string{"+1 Shadow Herb", "+2 Scrap Iron", "1 fail", "2 close calls"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("footer %q missing %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Singular "close call" when noise=1.
|
||||||
|
s.NoiseInts = 1
|
||||||
|
got = renderAutoHarvestFooter(s)
|
||||||
|
if !strings.Contains(got, "1 close call") || strings.Contains(got, "close calls") {
|
||||||
|
t.Errorf("expected singular 'close call', got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderWalkTally_EmptyIsEmpty(t *testing.T) {
|
||||||
|
if got := renderWalkTally(map[string]int{}, map[string]string{}); got != "" {
|
||||||
|
t.Errorf("expected empty tally, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderWalkTally_SortsByID(t *testing.T) {
|
||||||
|
got := renderWalkTally(
|
||||||
|
map[string]int{"zinc": 1, "alpha": 2, "mid": 3},
|
||||||
|
map[string]string{"zinc": "Zinc", "alpha": "Alphite", "mid": "Mid Ore"},
|
||||||
|
)
|
||||||
|
// "alpha" < "mid" < "zinc" so the rendered order is Alphite, Mid Ore, Zinc.
|
||||||
|
if !strings.HasPrefix(got, "**Walk haul:** 2 Alphite") {
|
||||||
|
t.Errorf("tally not sorted by id: %q", got)
|
||||||
|
}
|
||||||
|
if iAlpha, iZinc := strings.Index(got, "Alphite"), strings.Index(got, "Zinc"); iAlpha >= iZinc {
|
||||||
|
t.Errorf("Alphite should precede Zinc: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRarePendingFooter_EmptyIsEmpty(t *testing.T) {
|
||||||
|
if got := renderRarePendingFooter(nil); got != "" {
|
||||||
|
t.Errorf("expected empty rare-pending footer, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRarePendingFooter_DedupsByID(t *testing.T) {
|
||||||
|
r := ZoneResource{ID: "war_standard", Name: "Hobgoblin War Standard", Rarity: RarityRare, Action: HarvestScavenge}
|
||||||
|
got := renderRarePendingFooter([]ZoneResource{r, r, r})
|
||||||
|
// Single mention even with 3 copies in the pending slice.
|
||||||
|
if strings.Count(got, "Hobgoblin War Standard") != 1 {
|
||||||
|
t.Errorf("expected single mention, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "!scavenge") {
|
||||||
|
t.Errorf("expected action hint '!scavenge' in footer, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Rare") {
|
||||||
|
t.Errorf("expected rarity label in footer, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRarePendingFooter_MultiplePlural(t *testing.T) {
|
||||||
|
a := ZoneResource{ID: "a", Name: "Aaa", Rarity: RarityRare, Action: HarvestForage}
|
||||||
|
b := ZoneResource{ID: "b", Name: "Bbb", Rarity: RarityVeryRare, Action: HarvestForage}
|
||||||
|
got := renderRarePendingFooter([]ZoneResource{a, b})
|
||||||
|
if !strings.Contains(got, "are sitting in this room") {
|
||||||
|
t.Errorf("expected plural verb for two items, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -469,6 +469,11 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
|||||||
var finalMsg string
|
var finalMsg string
|
||||||
rooms := 0
|
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++ {
|
for i := 0; i < autopilotRoomCap; i++ {
|
||||||
// Pre-iteration interrupts: low HP / low SU. Skip on the first
|
// Pre-iteration interrupts: low HP / low SU. Skip on the first
|
||||||
// iteration so the player always gets at least one room out of
|
// iteration so the player always gets at least one room out of
|
||||||
@@ -518,6 +523,56 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
|||||||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
||||||
exp = fresh
|
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 == "" {
|
if finalMsg == "" {
|
||||||
@@ -526,6 +581,13 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
|||||||
// stopped on its own clock, not because something needs them.
|
// stopped on its own clock, not because something needs them.
|
||||||
finalMsg = autopilotFooter(stopOK, rooms)
|
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)
|
return p.streamFlow(ctx.Sender, stream, finalMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +629,10 @@ func autopilotFooter(reason stopReason, rooms int) string {
|
|||||||
return "" // run-complete block is the final; no footer
|
return "" // run-complete block is the final; no footer
|
||||||
case stopBlocked:
|
case stopBlocked:
|
||||||
return "" // "finish your fight first" is the final; no footer
|
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
|
default: // stopOK — hit the room cap
|
||||||
return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
|
return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ const (
|
|||||||
stopEnded // patrol or room resolution killed the player
|
stopEnded // patrol or room resolution killed the player
|
||||||
stopComplete // run cleared (boss down, no outgoing edges)
|
stopComplete // run cleared (boss down, no outgoing edges)
|
||||||
stopBlocked // an active CombatSession blocks the advance
|
stopBlocked // an active CombatSession blocks the advance
|
||||||
|
stopRareNode // auto-harvest spotted a Rare+ node; player should decide
|
||||||
|
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
|
||||||
)
|
)
|
||||||
|
|
||||||
// advanceResult bundles the staged narration + dispatch shape of one
|
// advanceResult bundles the staged narration + dispatch shape of one
|
||||||
|
|||||||
Reference in New Issue
Block a user