mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user