mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
H1: Josie harvest semantics in autopilot
Replace retry-to-success grind with one-roll-per-charge. Every node swing — Common through Legendary — consumes a charge regardless of outcome. Rarity no longer pauses the autopilot. Removed: autoHarvestPerNodeAttempts cap, isRarePlus filter, RarePending field, renderRarePendingFooter, stopRareNode reason. Pluralize "fails" since whiffs are now expected. Per gogobee_harvest_charges_plan.md H1.
This commit is contained in:
@@ -11,31 +11,22 @@ import (
|
|||||||
|
|
||||||
// Phase 2 of expedition autopilot — auto-harvest on room entry.
|
// 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
|
// - All applicable actions auto-run on every walked room. Class- and
|
||||||
// kill-gated nodes filter themselves out via the existing per-resource
|
// kill-gated nodes filter themselves out via the existing per-resource
|
||||||
// restrictions.
|
// restrictions.
|
||||||
// - Rare+ nodes are NOT auto-attempted. If any sit available in the
|
// - Every node — Common through Legendary — is auto-attempted. Rarity
|
||||||
// room, autopilot stops with stopRareNode so the player can spend the
|
// no longer pauses the walk; a whiff on a Rare node is a whiff.
|
||||||
// attempt deliberately.
|
// - A node spawns with N charges. Auto-harvest swings exactly once per
|
||||||
// - Each eligible (Common/Uncommon) node is ground until it runs dry or
|
// remaining charge. Success or failure both consume a charge — no
|
||||||
// until autoHarvestPerNodeAttempts attempts have been spent — whichever
|
// retry-to-success. This is the entire point of the redesign.
|
||||||
// comes first. Failed rolls re-roll without depleting charges, same
|
// - Noise interrupts apply threat++ and continue the per-charge loop
|
||||||
// as a manual player retrying !forage/!scavenge/etc. The cap stops
|
// (parity with manual). Standard/Elite/Patrol combat interrupts
|
||||||
// the walk from getting stuck on a hard-DC node forever.
|
// hard-stop the walk: stopEnded on death, stopHarvestCombat otherwise.
|
||||||
// - 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.
|
// - 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.
|
// 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
|
// autoHarvestSummary holds the aggregated outcome for one room's
|
||||||
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
||||||
// also folds it into a walk-wide tally for the closing block.
|
// 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.
|
// autoHarvestResult is what autoHarvestRoom returns to the autopilot loop.
|
||||||
type autoHarvestResult struct {
|
type autoHarvestResult struct {
|
||||||
Summary autoHarvestSummary
|
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
|
||||||
CombatNarr string // non-empty = a combat interrupt fired during the pass
|
PlayerEnded bool // true = combat killed the player
|
||||||
PlayerEnded bool // true = combat killed the player
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// allHarvestActions lists every action autopilot will try per room. Order
|
// allHarvestActions lists every action autopilot will try per room. Order
|
||||||
@@ -66,16 +56,6 @@ var allHarvestActions = []HarvestAction{
|
|||||||
HarvestCommune,
|
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
|
// autoHarvestRoom runs a single auto-harvest pass on the player's current
|
||||||
// room. Called by expeditionCmdRun between walked rooms. Does not advance
|
// room. Called by expeditionCmdRun between walked rooms. Does not advance
|
||||||
// the room; only resolves nodes already at the player's feet.
|
// the room; only resolves nodes already at the player's feet.
|
||||||
@@ -137,17 +117,9 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRarePlus(rdef.Rarity) {
|
// Josie semantics: swing exactly once per remaining charge.
|
||||||
// Defer the decision to the player; don't consume the attempt.
|
// Each swing consumes a charge regardless of outcome.
|
||||||
res.RarePending = append(res.RarePending, rdef)
|
for n.CurrentCharges > 0 {
|
||||||
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++ {
|
|
||||||
// Interrupt roll, same model as handleHarvestCmd.
|
// Interrupt roll, same model as handleHarvestCmd.
|
||||||
interrupt, intTotal := resolveCombatInterrupt(
|
interrupt, intTotal := resolveCombatInterrupt(
|
||||||
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
||||||
@@ -204,9 +176,10 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
_ = grantHarvestYield(userID, rdef, grantedQty)
|
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||||||
res.Summary.Yields[rdef.ID] += grantedQty
|
res.Summary.Yields[rdef.ID] += grantedQty
|
||||||
res.Summary.Names[rdef.ID] = rdef.Name
|
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",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
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]))
|
parts = append(parts, fmt.Sprintf("+%d %s", s.Yields[k], s.Names[k]))
|
||||||
}
|
}
|
||||||
if s.Fails > 0 {
|
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 {
|
if s.NoiseInts > 0 {
|
||||||
noun := "close call"
|
noun := "close call"
|
||||||
@@ -276,31 +253,3 @@ func renderWalkTally(yields map[string]int, names map[string]string) string {
|
|||||||
}
|
}
|
||||||
return b.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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,26 +10,6 @@ import (
|
|||||||
// region state) and lives behind the same setupAuditTestDB gate as the
|
// region state) and lives behind the same setupAuditTestDB gate as the
|
||||||
// other expedition tests.
|
// 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) {
|
func TestRenderAutoHarvestFooter_EmptyIsEmpty(t *testing.T) {
|
||||||
s := autoHarvestSummary{Yields: map[string]int{}, Names: map[string]string{}}
|
s := autoHarvestSummary{Yields: map[string]int{}, Names: map[string]string{}}
|
||||||
if got := renderAutoHarvestFooter(s); got != "" {
|
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") {
|
if !strings.Contains(got, "1 close call") || strings.Contains(got, "close calls") {
|
||||||
t.Errorf("expected singular 'close call', got %q", got)
|
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) {
|
func TestRenderWalkTally_EmptyIsEmpty(t *testing.T) {
|
||||||
@@ -78,33 +64,3 @@ func TestRenderWalkTally_SortsByID(t *testing.T) {
|
|||||||
t.Errorf("Alphite should precede Zinc: %q", got)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -627,8 +627,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
|
|
||||||
// Phase 2 — auto-harvest the room we just walked into. Aggregates
|
// Phase 2 — auto-harvest the room we just walked into. Aggregates
|
||||||
// into walkYields/walkNames; per-room footer goes straight into
|
// into walkYields/walkNames; per-room footer goes straight into
|
||||||
// the stream. Rare+ nodes and combat interrupts hard-stop the
|
// the stream. Combat interrupts hard-stop the walk with a
|
||||||
// walk with dedicated stop reasons.
|
// dedicated stop reason; Josie semantics mean rarity no longer
|
||||||
|
// pauses the autopilot.
|
||||||
char, cerr := LoadDnDCharacter(ctx.Sender)
|
char, cerr := LoadDnDCharacter(ctx.Sender)
|
||||||
if cerr != nil || char == nil {
|
if cerr != nil || char == nil {
|
||||||
continue
|
continue
|
||||||
@@ -663,15 +664,6 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
reason = r
|
reason = r
|
||||||
break
|
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
|
// Refresh exp once more — auto-harvest may have bumped threat
|
||||||
// via noise interrupts.
|
// via noise interrupts.
|
||||||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
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
|
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:
|
case stopHarvestCombat:
|
||||||
return fmt.Sprintf("⏸ **Autopilot paused — interrupted while gathering** (after %s). Reassess and `!expedition run` to continue.", roomsStr)
|
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
|
||||||
|
|||||||
@@ -384,7 +384,6 @@ 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
|
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
|
||||||
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
|
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user