diff --git a/internal/plugin/dnd_expedition_autopilot_harvest.go b/internal/plugin/dnd_expedition_autopilot_harvest.go index 50c3277..9dd7255 100644 --- a/internal/plugin/dnd_expedition_autopilot_harvest.go +++ b/internal/plugin/dnd_expedition_autopilot_harvest.go @@ -11,31 +11,22 @@ import ( // Phase 2 of expedition autopilot — auto-harvest on room entry. // -// Design (settled 2026-05-14, harvest-until-dry added 2026-05-16): +// Design (Josie semantics, settled 2026-05-17): // - 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. -// - Each eligible (Common/Uncommon) node is ground until it runs dry or -// until autoHarvestPerNodeAttempts attempts have been spent — whichever -// comes first. Failed rolls re-roll without depleting charges, same -// as a manual player retrying !forage/!scavenge/etc. The cap stops -// the walk from getting stuck on a hard-DC node forever. -// - Noise interrupts apply threat++ and continue (parity with manual). -// - Standard/Elite/Patrol combat interrupts hard-stop the walk: -// stopEnded on death, stopHarvestCombat otherwise. +// - Every node — Common through Legendary — is auto-attempted. Rarity +// no longer pauses the walk; a whiff on a Rare node is a whiff. +// - A node spawns with N charges. Auto-harvest swings exactly once per +// remaining charge. Success or failure both consume a charge — no +// retry-to-success. This is the entire point of the redesign. +// - Noise interrupts apply threat++ and continue the per-charge loop +// (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 +// - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally // across all rooms. -// autoHarvestPerNodeAttempts bounds how many times autopilot will swing -// at a single node before giving up for this visit. Sized to handle a -// typical Common/Uncommon node (charges 1–3, success rate 50–80%) with -// margin, while keeping a DC-20 outlier from spinning indefinitely. -const autoHarvestPerNodeAttempts = 8 - // 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. @@ -49,9 +40,8 @@ type autoHarvestSummary struct { // 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 + 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 @@ -66,16 +56,6 @@ var allHarvestActions = []HarvestAction{ 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. @@ -137,17 +117,9 @@ func (p *AdventurePlugin) autoHarvestRoom( continue } - if isRarePlus(rdef.Rarity) { - // Defer the decision to the player; don't consume the attempt. - res.RarePending = append(res.RarePending, rdef) - continue - } - - // Grind this node until it's dry or until the per-node attempt - // cap fires. A failed roll re-rolls (mirrors a manual player - // pressing the harvest command again); only successful pulls - // decrement charges. - for attempt := 0; attempt < autoHarvestPerNodeAttempts && n.CurrentCharges > 0; attempt++ { + // Josie semantics: swing exactly once per remaining charge. + // Each swing consumes a charge regardless of outcome. + for n.CurrentCharges > 0 { // Interrupt roll, same model as handleHarvestCmd. interrupt, intTotal := resolveCombatInterrupt( exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil) @@ -204,9 +176,10 @@ func (p *AdventurePlugin) autoHarvestRoom( _ = grantHarvestYield(userID, rdef, grantedQty) res.Summary.Yields[rdef.ID] += grantedQty res.Summary.Names[rdef.ID] = rdef.Name - n.CurrentCharges-- - persistNodes = true } + // Charge is spent either way under Josie semantics. + n.CurrentCharges-- + persistNodes = true _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest", fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s", @@ -243,7 +216,11 @@ func renderAutoHarvestFooter(s autoHarvestSummary) string { 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)) + noun := "fail" + if s.Fails > 1 { + noun = "fails" + } + parts = append(parts, fmt.Sprintf("%d %s", s.Fails, noun)) } if s.NoiseInts > 0 { noun := "close call" @@ -276,31 +253,3 @@ func renderWalkTally(yields map[string]int, names map[string]string) string { } 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 index ae1171e..15fa751 100644 --- a/internal/plugin/dnd_expedition_autopilot_harvest_test.go +++ b/internal/plugin/dnd_expedition_autopilot_harvest_test.go @@ -10,26 +10,6 @@ import ( // 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 != "" { @@ -57,6 +37,12 @@ func TestRenderAutoHarvestFooter_YieldsAndFailsAndNoise(t *testing.T) { if !strings.Contains(got, "1 close call") || strings.Contains(got, "close calls") { t.Errorf("expected singular 'close call', got %q", got) } + // Plural "fails" when fails>1 — Josie semantics make this common. + s.Fails = 3 + got = renderAutoHarvestFooter(s) + if !strings.Contains(got, "3 fails") { + t.Errorf("expected plural 'fails', got %q", got) + } } func TestRenderWalkTally_EmptyIsEmpty(t *testing.T) { @@ -78,33 +64,3 @@ func TestRenderWalkTally_SortsByID(t *testing.T) { 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 eb6e602..5850282 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -627,8 +627,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com // 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. + // the stream. Combat interrupts hard-stop the walk with a + // dedicated stop reason; Josie semantics mean rarity no longer + // pauses the autopilot. char, cerr := LoadDnDCharacter(ctx.Sender) if cerr != nil || char == nil { continue @@ -663,15 +664,6 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com reason = r break } - if len(hr.RarePending) > 0 { - finalMsg = renderRarePendingFooter(hr.RarePending) - if tally := renderWalkTally(walkYields, walkNames); tally != "" { - finalMsg += "\n\n" + tally - } - reason = stopRareNode - break - } - // Refresh exp once more — auto-harvest may have bumped threat // via noise interrupts. if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil { @@ -738,8 +730,6 @@ 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 diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 2d1a480..139e289 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -384,7 +384,6 @@ 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 stopPreflight // pre-iteration preflight tripped (low HP / low SU) )