From f404f952025f500b96d282b1397dd5e4df1e5826 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 23:13:01 -0700 Subject: [PATCH] Expedition autopilot Phase 2: auto-harvest on room entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dnd_expedition_autopilot_harvest.go | 292 ++++++++++++++++++ .../dnd_expedition_autopilot_harvest_test.go | 110 +++++++ internal/plugin/dnd_expedition_cmd.go | 66 ++++ internal/plugin/dnd_zone_cmd.go | 2 + 4 files changed, 470 insertions(+) create mode 100644 internal/plugin/dnd_expedition_autopilot_harvest.go create mode 100644 internal/plugin/dnd_expedition_autopilot_harvest_test.go diff --git a/internal/plugin/dnd_expedition_autopilot_harvest.go b/internal/plugin/dnd_expedition_autopilot_harvest.go new file mode 100644 index 0000000..5f44214 --- /dev/null +++ b/internal/plugin/dnd_expedition_autopilot_harvest.go @@ -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)) +} diff --git a/internal/plugin/dnd_expedition_autopilot_harvest_test.go b/internal/plugin/dnd_expedition_autopilot_harvest_test.go new file mode 100644 index 0000000..ae1171e --- /dev/null +++ b/internal/plugin/dnd_expedition_autopilot_harvest_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 02e5800..af339e3 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -469,6 +469,11 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { var finalMsg string 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++ { // Pre-iteration interrupts: low HP / low SU. Skip on the first // 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 { 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 == "" { @@ -526,6 +581,13 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { // stopped on its own clock, not because something needs them. 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) } @@ -567,6 +629,10 @@ func autopilotFooter(reason stopReason, rooms int) string { return "" // run-complete block is the final; no footer case stopBlocked: 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 return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr) } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 6d518df..65c2126 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -384,6 +384,8 @@ const ( stopEnded // patrol or room resolution killed the player stopComplete // run cleared (boss down, no outgoing edges) 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