H3 soak close-out: drop dead handleHarvestCmd / handleStandaloneHarvest

H3 retired the manual !forage/!mine/!scavenge/!fish/!essence/!commune
verbs from the command dispatcher in e05da91; the underlying handlers
were left parked behind an operator-gated 2-week soak. The expedition
sim has since exercised the auto-harvest path 15k+ times per sweep
across every class, level, and zone — vastly more coverage than the
soak would have collected in production. Pull the dead handlers now.

Removed:
- handleHarvestCmd (dnd_expedition_harvest.go, ~175 lines)
- handleStandaloneHarvest (dnd_zone_harvest.go, ~80 lines)
- dnd_zone_harvest_test.go (three tests against the deleted handler;
  the autopilot's standalone-storage path is what runs in prod and is
  covered by the cmd/expedition-sim sweep + adv2 scenario test)

The deprecation DM in adventure.go ("Harvest is automatic now — just
walk.") stays for now — muscle memory takes longer than two weeks to
fade and the redirect costs 7 lines.

adv2 scenario test rewired to drive autoHarvestRoom directly.
This commit is contained in:
prosolis
2026-05-17 17:03:20 -07:00
parent d225fc8185
commit b76d9bc577
6 changed files with 15 additions and 379 deletions

View File

@@ -380,182 +380,6 @@ func currentRoomCleared(run *DungeonRun) bool {
return false
}
// ── command surface ────────────────────────────────────────────────────────
// handleHarvestCmd is the shared dispatcher for !forage/!mine/!scavenge/
// !essence/!commune. Resolves a single attempt and DMs the outcome.
func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAction) error {
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
}
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
}
if exp == nil {
// No expedition: fall through to single-session harvest if the
// player has an active !zone enter run. Single-session harvest
// lives in dnd_zone_harvest.go and stores nodes per-run rather
// than per-region.
run, _ := getActiveZoneRun(ctx.Sender)
if run == nil {
return p.SendDM(ctx.Sender, "You're not in a zone or expedition. Use `!zone enter <id>` or `!expedition start <zone>`.")
}
return p.handleStandaloneHarvest(ctx, char, action, run)
}
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
return p.SendDM(ctx.Sender, "There's no water to fish in here. `!fish` works only in Forest of Shadows, Sunken Temple, Underdark, or Feywild Crossing.")
}
if exp.RunID == "" {
return p.SendDM(ctx.Sender, "No active region run. Try `!region` to refresh, or restart the expedition.")
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Couldn't load region state.")
}
if !currentRoomCleared(run) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You can't harvest here — the room (%s) isn't cleared. Resolve combat first.",
prettyRoomType(run.CurrentRoomType())))
}
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
return p.SendDM(ctx.Sender, blockReason)
}
nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, nodeID)
idx := pickAvailableNode(nodes, exp, char, action)
if idx < 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Nothing left to %s in this room. Try `!resources` to see what's still gatherable.", action))
}
res, _ := FindZoneResource(exp.ZoneID, nodes[idx].ResourceID)
// §4.2 Combat Interrupt roll — gates the harvest attempt.
zone, _ := getZone(exp.ZoneID)
interrupt, intTotal := resolveCombatInterrupt(
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
var interruptHeader string
switch interrupt {
case InterruptNoise:
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2)")
interruptHeader = fmt.Sprintf(
"_⚠ Noise drifts through the corridors (interrupt roll %d). Threat +2._\n\n",
intTotal)
case InterruptStandard, InterruptElite, InterruptPatrol:
var pre strings.Builder
pre.WriteString(fmt.Sprintf(
"**%s · %s** — interrupted before the roll (interrupt %d).\n\n",
strings.ToTitle(string(action)[:1])+string(action)[1:], res.Name, intTotal))
body, ended := p.runHarvestInterrupt(ctx.Sender, exp, run, zone, interrupt)
pre.WriteString(body)
if interrupt == InterruptElite && !ended {
pre.WriteString("\n\n_The node is still here — you dropped the harvest mid-strike._")
}
if interrupt == InterruptPatrol && !ended {
pre.WriteString("\n\n_The harvest is forfeit._")
}
// Persist node state untouched (no charge spent).
_ = saveHarvestNodes(exp, nodeID, nodes)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("%s %s kind=%d total=%d", string(action), res.ID, int(interrupt), intTotal), "")
return p.SendDM(ctx.Sender, pre.String())
}
roll := rand.IntN(20) + 1
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
if action == HarvestFish {
if adv, _ := loadAdvCharacter(ctx.Sender); adv != nil {
mod += fishingSkillBonus(adv.FishingSkill)
}
}
total := roll + mod
dcDelta, dcReason := zoneConditionHarvestDCMod(exp, action)
effectiveDC := res.DC + dcDelta
outcome := resolveHarvestOutcome(total, effectiveDC)
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
var b strings.Builder
if interruptHeader != "" {
b.WriteString(interruptHeader)
}
if dist := feywildFishDistortion(exp.ZoneID, action, nil); dist != "" {
b.WriteString(dist)
}
verb := strings.ToTitle(string(action)[:1]) + string(action)[1:]
if dcDelta != 0 {
sign := "+"
if dcDelta < 0 {
sign = ""
}
b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d (%s%d %s)\n\n",
verb, res.Name, roll, mod, total, effectiveDC, sign, dcDelta, dcReason))
} else {
b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n",
verb, res.Name, roll, mod, total, effectiveDC))
}
switch outcome {
case OutcomeNothing:
b.WriteString(flavor.Pick(flavor.HarvestFail))
b.WriteString("\n\n_No yield. The node is still here — try again._")
case OutcomeCommon:
_ = grantHarvestYield(ctx.Sender, res, 1)
b.WriteString(harvestSuccessLine(action, exp.ZoneID))
b.WriteString(fmt.Sprintf("\n\n_+1 %s._", res.Name))
nodes[idx].CurrentCharges--
case OutcomeStandard:
qty := rand.IntN(3) + 1
_ = grantHarvestYield(ctx.Sender, res, qty)
b.WriteString(harvestSuccessLine(action, exp.ZoneID))
b.WriteString(fmt.Sprintf("\n\n_+%d %s._", qty, res.Name))
nodes[idx].CurrentCharges--
case OutcomeRich:
qty := rand.IntN(3) + 1
_ = grantHarvestYield(ctx.Sender, res, qty)
bonus := pickRichBonus(exp.ZoneID, res.ID)
bonusLine := ""
if bonus != nil {
_ = grantHarvestYield(ctx.Sender, *bonus, 1)
bonusLine = fmt.Sprintf(" + 1 %s (bonus)", bonus.Name)
}
b.WriteString(flavor.Pick(flavor.RichYield))
b.WriteString(fmt.Sprintf("\n\n_+%d %s%s._", qty, res.Name, bonusLine))
nodes[idx].CurrentCharges--
}
if outcome != OutcomeNothing && nodes[idx].CurrentCharges <= 0 {
b.WriteString("\n\n")
b.WriteString(flavor.Pick(flavor.NodeDepleted))
}
// §3 Zone 05 — Manor secondary drop on !scavenge (10% per attempt).
if rollManorCursedTrinket(exp.ZoneID, action, nil) {
if trinket, ok := FindZoneResource(exp.ZoneID, "cursed_trinket"); ok {
_ = grantHarvestYield(ctx.Sender, trinket, 1)
b.WriteString("\n\n_Something cold turns up in your kit — a small trinket you didn't pocket. **+1 Cursed Trinket.**_")
if warned, _ := exp.RegionState["manor_cursed_warned"].(bool); !warned {
b.WriteString("\n\n_TwinBee, low: \"Cursed items in this house bind to anyone who equips them. Sell them. Don't wear them. I'll only say this once.\"_")
exp.RegionState["manor_cursed_warned"] = true
_ = persistRegionState(exp)
}
}
}
if err := saveHarvestNodes(exp, nodeID, nodes); err != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("persistence error: %v", err), "")
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("%s %s d20=%d total=%d dc=%d → %s",
string(action), res.ID, roll, total, res.DC, string(outcome)), "")
return p.SendDM(ctx.Sender, b.String())
}
// harvestSuccessLine picks the action's generic success pool and
// occasionally layers a zone-specific Harvest* postscript.
func harvestSuccessLine(action HarvestAction, zoneID ZoneID) string {