H2: auto-harvest parity for !zone advance

Refactor autoHarvestRoom to accept a nillable Expedition and pull harvest
state from per-run storage when none is active. Wire the pass into
advanceOnceWithOpts so foreground !zone advance and the expedition
autopilot share the same Josie-semantics harvest loop. Standalone (no
expedition) skips threat/interrupts/log/region conditions; expedition
mode keeps the existing interrupt + threat behavior.
This commit is contained in:
prosolis
2026-05-17 12:33:59 -07:00
parent 20689a693b
commit 736494d945
3 changed files with 174 additions and 72 deletions

View File

@@ -10,6 +10,11 @@ import (
)
// Phase 2 of expedition autopilot — auto-harvest on room entry.
// H2 extends this so `!zone advance` calls the same path for standalone
// (no-expedition) zone runs. When exp is nil the loop uses the per-run
// storage in dnd_zone_run.harvest_nodes_json and skips the
// expedition-only side effects (threat, log, region conditions,
// combat-interrupt roll).
//
// Design (Josie semantics, settled 2026-05-17):
// - All applicable actions auto-run on every walked room. Class- and
@@ -23,6 +28,9 @@ import (
// - 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.
// Standalone (exp==nil) mode skips the interrupt roll entirely —
// zone runs already have a patrol mechanism baked into the advance
// pipeline.
// - No SU surcharge — manual !harvest is free; autopilot matches.
// - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally
// across all rooms.
@@ -57,10 +65,12 @@ var allHarvestActions = []HarvestAction{
}
// 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.
// room. exp may be nil for standalone zone runs (no expedition); in that
// mode the function uses per-run storage and skips threat/interrupts/log.
// Does not advance the room; only resolves nodes already at the player's
// feet.
func (p *AdventurePlugin) autoHarvestRoom(
userID id.UserID, exp *Expedition, char *DnDCharacter,
userID id.UserID, run *DungeonRun, char *DnDCharacter, exp *Expedition,
) (autoHarvestResult, error) {
res := autoHarvestResult{
Summary: autoHarvestSummary{
@@ -68,32 +78,44 @@ func (p *AdventurePlugin) autoHarvestRoom(
Names: map[string]string{},
},
}
if exp == nil || exp.RunID == "" {
return res, nil
}
run, err := getZoneRun(exp.RunID)
if err != nil || run == nil {
if run == nil {
return res, nil
}
if !currentRoomCleared(run) {
return res, nil
}
zoneID := run.ZoneID
if exp != nil {
zoneID = exp.ZoneID
}
nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, nodeID)
var nodes []HarvestNode
if exp != nil {
nodes = loadHarvestNodes(exp, nodeID)
} else {
loaded, lerr := loadStandaloneHarvestNodes(run.RunID, zoneID, nodeID)
if lerr != nil {
return res, nil
}
nodes = loaded
}
if len(nodes) == 0 {
return res, nil
}
zone, _ := getZone(exp.ZoneID)
zone, _ := getZone(zoneID)
persistNodes := false
for _, action := range allHarvestActions {
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
if action == HarvestFish && !isFishingZone(zoneID) {
continue
}
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
continue
if exp != nil {
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
continue
}
}
// One pass per action. Iterate every eligible node (not just the
@@ -103,43 +125,55 @@ func (p *AdventurePlugin) autoHarvestRoom(
if n.CurrentCharges <= 0 {
continue
}
rdef, ok := FindZoneResource(exp.ZoneID, n.ResourceID)
rdef, ok := FindZoneResource(zoneID, n.ResourceID)
if !ok || rdef.Action != action {
continue
}
if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
continue
}
if rdef.RequiresKill != "" && !regionKillsContains(exp, rdef.RequiresKill) {
continue
if rdef.RequiresKill != "" {
if exp == nil || !regionKillsContains(exp, rdef.RequiresKill) {
continue
}
}
if rdef.ConditionalEvent != "" && !regionEventActive(exp, rdef.ConditionalEvent) {
continue
if rdef.ConditionalEvent != "" {
if exp == nil || !regionEventActive(exp, rdef.ConditionalEvent) {
continue
}
}
// 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)
switch interrupt {
case InterruptNoise:
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
res.Summary.NoiseInts++
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
// fall through to the d20 roll, same as manual harvest.
case InterruptStandard, InterruptElite, InterruptPatrol:
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
res.CombatNarr = body
res.PlayerEnded = ended
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
if persistNodes {
_ = saveHarvestNodes(exp, nodeID, nodes)
// Interrupt roll, same model as handleHarvestCmd
// 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)
switch interrupt {
case InterruptNoise:
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
res.Summary.NoiseInts++
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
// fall through to the d20 roll, same as manual harvest.
case InterruptStandard, InterruptElite, InterruptPatrol:
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
res.CombatNarr = body
res.PlayerEnded = ended
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
if persistNodes {
if exp != nil {
_ = saveHarvestNodes(exp, nodeID, nodes)
} else {
_ = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
}
}
return res, nil
}
return res, nil
}
// Resolve the harvest attempt.
@@ -151,7 +185,10 @@ func (p *AdventurePlugin) autoHarvestRoom(
}
}
total := roll + mod
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
dcDelta := 0
if exp != nil {
dcDelta, _ = zoneConditionHarvestDCMod(exp, action)
}
effectiveDC := rdef.DC + dcDelta
outcome := resolveHarvestOutcome(total, effectiveDC)
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
@@ -166,7 +203,7 @@ func (p *AdventurePlugin) autoHarvestRoom(
grantedQty = rand.IntN(3) + 1
case OutcomeRich:
grantedQty = rand.IntN(3) + 1
if bonus := pickRichBonus(exp.ZoneID, rdef.ID); bonus != nil {
if bonus := pickRichBonus(zoneID, rdef.ID); bonus != nil {
_ = grantHarvestYield(userID, *bonus, 1)
res.Summary.Yields[bonus.ID] += 1
res.Summary.Names[bonus.ID] = bonus.Name
@@ -181,17 +218,25 @@ func (p *AdventurePlugin) autoHarvestRoom(
n.CurrentCharges--
persistNodes = true
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
}
}
}
}
if persistNodes {
if err := saveHarvestNodes(exp, nodeID, nodes); err != nil {
var perr error
if exp != nil {
perr = saveHarvestNodes(exp, nodeID, nodes)
} else {
perr = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
}
if perr != nil && exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
fmt.Sprintf("autopilot persistence error: %v", err), "")
fmt.Sprintf("autopilot persistence error: %v", perr), "")
}
}
return res, nil

View File

@@ -625,42 +625,42 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
break
}
// Phase 2 — auto-harvest the room we just walked into. Aggregates
// into walkYields/walkNames; per-room footer goes straight into
// 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
}
hr, herr := p.autoHarvestRoom(ctx.Sender, exp, char)
if herr != nil {
continue
}
// Phase 2 — auto-harvest the room we just walked into.
// advanceOnceWithOpts now runs the pass inline and surfaces the
// result via res.harvest + res.harvestFooter. The "✓ cleared /
// next room" line in res.final already has the per-room footer
// appended (and the combat-interrupt narration if one fired);
// we still need to aggregate into walkYields/walkNames and to
// split out the harvest-combat path so the walk-end footer/tally
// rendering matches the pre-H2 behavior.
hr := res.harvest
for k, v := range hr.Summary.Yields {
walkYields[k] += v
walkNames[k] = hr.Summary.Names[k]
}
if footer := renderAutoHarvestFooter(hr.Summary); footer != "" {
stream = append(stream, footer)
}
if hr.CombatNarr != "" {
// A harvest interrupt fired combat. The combat narration
// becomes the final block; reason classifies death vs survive.
// A harvest interrupt fired combat. res.final already
// includes the ✓-cleared block, the harvest footer, and the
// combat narration; just attach the walk footer/tally and
// stop.
r := stopHarvestCombat
if hr.PlayerEnded {
r = stopEnded
}
footer := autopilotFooter(r, rooms)
finalMsg = hr.CombatNarr
finalMsg = res.final
if footer != "" {
finalMsg += "\n\n" + footer
}
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
finalMsg += "\n\n" + tally
}
// Drop the just-pushed res.final from the stream so the
// combined finalMsg lands as the closer, not as a duplicate
// phase entry.
if len(stream) > 0 && stream[len(stream)-1] == res.final {
stream = stream[:len(stream)-1]
}
reason = r
break
}

View File

@@ -404,6 +404,12 @@ type advanceResult struct {
// re-emit the Boss/Elite doorway gate (and produce a duplicate
// "Room X/Y — Boss" line in the same DM).
nextRoomType RoomType
// harvest carries the auto-harvest pass result for the room the
// player just walked into (Josie H2 — auto-harvest parity between
// `!zone advance` and `!expedition run`). Zero value when no
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
harvest autoHarvestResult
harvestFooter string
}
// zoneCmdAdvance resolves the room the player is currently standing in,
@@ -622,14 +628,65 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
reason: stopFork,
}, nil
}
return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
reason: stopOK,
nextRoomType: next,
}, nil
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)
// H2 — auto-harvest the room the player just walked into. Only fires
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
// currentRoomCleared / autoHarvestRoom's no-op when there's nothing
// to harvest). Uses standalone storage when there's no active
// expedition, expedition region_state when there is.
hr, hfooter := p.runHarvestForAdvance(ctx.Sender, run.RunID, c, next)
if hfooter != "" {
finalMsg += "\n\n" + hfooter
}
if hr.CombatNarr != "" {
// Harvest interrupt fired combat. Append the narration to the
// final block; reclassify reason for the autopilot loop so it
// can break with the right footer/tally treatment.
finalMsg += "\n\n" + hr.CombatNarr
}
res := advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: finalMsg,
reason: stopOK,
nextRoomType: next,
harvest: hr,
harvestFooter: hfooter,
}
if hr.CombatNarr != "" {
res.reason = stopHarvestCombat
if hr.PlayerEnded {
res.reason = stopEnded
}
}
return res, nil
}
// runHarvestForAdvance runs an auto-harvest pass on the room the player
// just walked into and returns the result plus a rendered per-room
// footer. Reloads the run so the harvest sees the post-graph-advance
// current node; binds to the active expedition when one exists, falls
// through to standalone (per-run) storage otherwise. nextRoomType is a
// fast-path filter so we don't even touch the DB for Entry/Trap/Elite/
// Boss rooms — autoHarvestRoom would no-op on them anyway.
func (p *AdventurePlugin) runHarvestForAdvance(
userID id.UserID, runID string, c *DnDCharacter, nextRoomType RoomType,
) (autoHarvestResult, string) {
if nextRoomType != RoomExploration {
return autoHarvestResult{}, ""
}
fresh, ferr := getZoneRun(runID)
if ferr != nil || fresh == nil {
return autoHarvestResult{}, ""
}
exp, _ := getActiveExpedition(userID)
hr, herr := p.autoHarvestRoom(userID, fresh, c, exp)
if herr != nil {
return autoHarvestResult{}, ""
}
return hr, renderAutoHarvestFooter(hr.Summary)
}
// formatNextRoomMessage builds the "✓ Cleared room X. ... Room Y/N —