From b76d9bc577442551ce62fb7496210295b36c1f25 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 17 May 2026 17:03:20 -0700 Subject: [PATCH] H3 soak close-out: drop dead handleHarvestCmd / handleStandaloneHarvest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/plugin/adv2_scenario_test.go | 28 ++- .../dnd_expedition_autopilot_harvest.go | 5 +- internal/plugin/dnd_expedition_harvest.go | 176 ------------------ internal/plugin/dnd_resource_registry.go | 2 +- internal/plugin/dnd_zone_harvest.go | 86 --------- internal/plugin/dnd_zone_harvest_test.go | 97 ---------- 6 files changed, 15 insertions(+), 379 deletions(-) delete mode 100644 internal/plugin/dnd_zone_harvest_test.go diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index 0c26965..3b619d3 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -1,7 +1,6 @@ package plugin import ( - "math/rand/v2" "testing" "time" @@ -331,22 +330,19 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) { t.Error("expected at least one harvest node seeded") } - // Try each harvest action a couple of times and watch for crashes. - // Outcomes are RNG-driven; we mainly want no panics + coherent state. - rng := rand.New(rand.NewPCG(7, 13)) - _ = rng - for _, action := range []HarvestAction{HarvestForage, HarvestMine} { - for i := 0; i < 3; i++ { - if err := p.handleHarvestCmd(MessageContext{Sender: uid}, action); err != nil { - t.Fatalf("handleHarvestCmd(%s) attempt %d: %v", action, i, err) - } - fresh, _ := getExpedition(exp.ID) - if fresh == nil { - t.Logf("expedition ended during harvest %s #%d", action, i) - break - } - t.Logf("after %s #%d: supplies=%v threat=%d", action, i, fresh.Supplies.Current, fresh.ThreatLevel) + // Drive the auto-harvest pass (post-H3 there is no manual command + // surface). Outcomes are RNG-driven; we mainly want no panics + + // coherent state. + for i := 0; i < 3; i++ { + if _, err := p.autoHarvestRoom(uid, run, c, exp); err != nil { + t.Fatalf("autoHarvestRoom attempt %d: %v", i, err) } + fresh, _ := getExpedition(exp.ID) + if fresh == nil { + t.Logf("expedition ended during auto-harvest #%d", i) + break + } + t.Logf("after auto-harvest #%d: supplies=%v threat=%d", i, fresh.Supplies.Current, fresh.ThreatLevel) } // !resources output path (no-op SendDM but should not error). diff --git a/internal/plugin/dnd_expedition_autopilot_harvest.go b/internal/plugin/dnd_expedition_autopilot_harvest.go index 8bc371c..bea92cb 100644 --- a/internal/plugin/dnd_expedition_autopilot_harvest.go +++ b/internal/plugin/dnd_expedition_autopilot_harvest.go @@ -146,9 +146,8 @@ func (p *AdventurePlugin) autoHarvestRoom( // 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 — - // expedition-only. Standalone zone runs already have a - // patrol mechanism in the advance pipeline. + // Interrupt roll, expedition-only. Standalone zone runs + // already have a patrol mechanism in the advance pipeline. if exp != nil { interrupt, intTotal := resolveCombatInterrupt( exp.ThreatLevel, int(zone.Tier), char.Class, zoneID, nil) diff --git a/internal/plugin/dnd_expedition_harvest.go b/internal/plugin/dnd_expedition_harvest.go index b2d1d75..b9b8b44 100644 --- a/internal/plugin/dnd_expedition_harvest.go +++ b/internal/plugin/dnd_expedition_harvest.go @@ -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 ` or `!expedition start `.") - } - 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 { diff --git a/internal/plugin/dnd_resource_registry.go b/internal/plugin/dnd_resource_registry.go index 6dbba7e..c9b4ae0 100644 --- a/internal/plugin/dnd_resource_registry.go +++ b/internal/plugin/dnd_resource_registry.go @@ -127,7 +127,7 @@ var zoneResources = map[ZoneID][]ZoneResource{ {ID: "hag_hair", Name: "Hag Hair", Action: HarvestCommune, DC: 18, Rarity: RarityRare, Type: "material", SellValue: 220, MaxCharges: 2, ClassRestrict: ClassCleric, RequiresKill: "hag"}, {ID: "timelock_amber", Name: "Timelock Amber", Action: HarvestForage, DC: 21, Rarity: RarityRare, Type: "material", SellValue: 290, MaxCharges: 2}, {ID: "thornmother_thorn", Name: "Thornmother's Thorn", Action: HarvestScavenge, DC: 25, Rarity: RarityVeryRare, Type: "material", SellValue: 1000, MaxCharges: 1, RequiresKill: "thornmother"}, - // §6.1 fish — fey streams; time-distortion event hook fires in handleHarvestCmd. + // §6.1 fish — fey streams; time-distortion event hook fires in the autopilot harvest pass. {ID: "moonlit_minnow", Name: "Moonlit Minnow", Action: HarvestFish, DC: 12, Rarity: RarityCommon, Type: "fish", SellValue: 12, MaxCharges: 2}, {ID: "dreaming_pike", Name: "Dreaming Pike", Action: HarvestFish, DC: 16, Rarity: RarityUncommon, Type: "fish", SellValue: 55, MaxCharges: 2}, {ID: "timeworn_koi", Name: "Timeworn Koi", Action: HarvestFish, DC: 21, Rarity: RarityRare, Type: "fish", SellValue: 280, MaxCharges: 2}, diff --git a/internal/plugin/dnd_zone_harvest.go b/internal/plugin/dnd_zone_harvest.go index 3464d44..361b90c 100644 --- a/internal/plugin/dnd_zone_harvest.go +++ b/internal/plugin/dnd_zone_harvest.go @@ -6,12 +6,9 @@ import ( "errors" "fmt" "log/slog" - "math/rand/v2" "strconv" - "strings" "gogobee/internal/db" - "gogobee/internal/flavor" "maunium.net/go/mautrix/id" ) @@ -94,89 +91,6 @@ func loadStandaloneHarvestTable(runID string) (map[string][]HarvestNode, error) return table, nil } -// handleStandaloneHarvest is the !forage/!mine/!fish/etc. fallback for -// players in a single-session !zone enter run with no active expedition. -// Mirrors the core of handleHarvestCmd minus the expedition-only side -// effects (threat ticks, region kills, cursed-trinket drops, log entries). -func (p *AdventurePlugin) handleStandaloneHarvest(ctx MessageContext, char *DnDCharacter, action HarvestAction, run *DungeonRun) error { - if action == HarvestFish && !isFishingZone(run.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 !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()))) - } - - nodeID := harvestNodeIDFor(run) - nodes, err := loadStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID) - if err != nil { - return p.SendDM(ctx.Sender, "Couldn't load harvest state.") - } - - // Reuse the expedition picker by passing a stub Expedition with just - // ZoneID set. The kill-gated and event-conditional predicates inside - // pickAvailableNode bail early on nil/empty RegionState — exactly the - // behavior we want for standalone (no kill tracking, no events). - stubExp := &Expedition{ZoneID: run.ZoneID} - idx := pickAvailableNode(nodes, stubExp, 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(run.ZoneID, nodes[idx].ResourceID) - - 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 - outcome := resolveHarvestOutcome(total, res.DC) - outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil) - - var b strings.Builder - verb := strings.ToTitle(string(action)[:1]) + string(action)[1:] - b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n", - verb, res.Name, roll, mod, total, res.DC)) - - 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, run.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, run.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) - b.WriteString(flavor.Pick(flavor.RichYield)) - b.WriteString(fmt.Sprintf("\n\n_+%d %s._", qty, res.Name)) - nodes[idx].CurrentCharges-- - } - - if outcome != OutcomeNothing && nodes[idx].CurrentCharges <= 0 { - b.WriteString("\n\n") - b.WriteString(flavor.Pick(flavor.NodeDepleted)) - } - - if err := saveStandaloneHarvestNodes(run.RunID, run.ZoneID, nodeID, nodes); err != nil { - slog.Error("standalone harvest: save nodes", "user", ctx.Sender, "run", run.RunID, "err", err) - } - - return p.SendDM(ctx.Sender, b.String()) -} - // Compile-time check that loadAdvCharacter is reachable; silences "unused // import" complaints if loadAdvCharacter changes signature in the future. var _ id.UserID diff --git a/internal/plugin/dnd_zone_harvest_test.go b/internal/plugin/dnd_zone_harvest_test.go deleted file mode 100644 index b9cc9b7..0000000 --- a/internal/plugin/dnd_zone_harvest_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package plugin - -import ( - "strings" - "testing" - - "gogobee/internal/db" - "maunium.net/go/mautrix/id" -) - -// Standalone-zone-run harvest path: with no active expedition but an -// active !zone enter run, !forage / !mine / !fish should land yields -// against per-run node storage. Mirrors the bug Rurina hit where harvest -// commands silently bounced because the handler hard-required an -// expedition. - -func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) { - setupAuditTestDB(t) - uid := id.UserID("@stand-harvest-route:example") - zoneCmdTestCharacter(t, uid, 4) - t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) }) - - run, err := startZoneRun(uid, ZoneForestShadows, 4, nil) - if err != nil { - t.Fatal(err) - } - // Mark room 0 (entry) as cleared so harvest is allowed there. - if !currentRoomCleared(run) { - t.Fatalf("entry room should auto-clear") - } - - p := &AdventurePlugin{} - // No expedition exists for this user — handler must fall through to - // the standalone path and not bounce with the expedition-required - // error message. - if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestForage); err != nil { - t.Fatalf("handleHarvestCmd: %v", err) - } - - // Side-effect check: the run row should now have non-empty - // harvest_nodes_json since saveStandaloneHarvestNodes ran. - var raw string - row := db.Get().QueryRow( - `SELECT harvest_nodes_json FROM dnd_zone_run WHERE run_id = ?`, run.RunID) - if err := row.Scan(&raw); err != nil { - t.Fatalf("scan harvest_nodes_json: %v", err) - } - if raw == "" || raw == "{}" { - t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw) - } - // Phase G: the entry node id comes from the zone graph (e.g. - // "forest_shadows.entry"), not the legacy ".r1" derivation. Use the - // production helper so this stays in sync with whatever node id - // saveStandaloneHarvestNodes actually wrote. - wantKey := "\"" + harvestNodeIDFor(run) + "\":" - if !strings.Contains(raw, wantKey) { - t.Errorf("expected entry node %s in harvest table, got %q", wantKey, raw) - } -} - -func TestStandaloneHarvest_BouncesWhenNoActiveAnything(t *testing.T) { - setupAuditTestDB(t) - uid := id.UserID("@stand-harvest-noctx:example") - zoneCmdTestCharacter(t, uid, 1) - - p := &AdventurePlugin{} - // No expedition, no zone run — should bounce cleanly with the new - // combined hint, not panic on a nil run. - if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestForage); err != nil { - t.Fatalf("handleHarvestCmd: %v", err) - } -} - -func TestStandaloneHarvest_FishGatedToFishingZones(t *testing.T) { - setupAuditTestDB(t) - uid := id.UserID("@stand-harvest-fish:example") - zoneCmdTestCharacter(t, uid, 1) - t.Cleanup(func() { cleanupZoneRuns(uid) }) - - // Goblin Warrens isn't a fishing zone — !fish must reject. - if _, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil); err != nil { - t.Fatal(err) - } - p := &AdventurePlugin{} - if err := p.handleHarvestCmd(MessageContext{Sender: uid}, HarvestFish); err != nil { - t.Fatalf("handleHarvestCmd: %v", err) - } - // (no easy way to assert the rejection without capturing SendDM — - // the no-error return + no node persistence is the contract.) - var raw string - _ = db.Get().QueryRow( - `SELECT COALESCE(harvest_nodes_json,'') FROM dnd_zone_run - WHERE user_id = ? ORDER BY started_at DESC LIMIT 1`, string(uid)).Scan(&raw) - if raw != "" && raw != "{}" { - t.Errorf("fish in non-fishing zone should not seed nodes; got %q", raw) - } -}