mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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,33 +78,45 @@ 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 exp != nil {
|
||||
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// One pass per action. Iterate every eligible node (not just the
|
||||
// lowest-DC one) so a room with two foragables gets two attempts.
|
||||
@@ -103,26 +125,33 @@ 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) {
|
||||
if rdef.RequiresKill != "" {
|
||||
if exp == nil || !regionKillsContains(exp, rdef.RequiresKill) {
|
||||
continue
|
||||
}
|
||||
if rdef.ConditionalEvent != "" && !regionEventActive(exp, rdef.ConditionalEvent) {
|
||||
}
|
||||
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 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, exp.ZoneID, nil)
|
||||
exp.ThreatLevel, int(zone.Tier), char.Class, zoneID, nil)
|
||||
switch interrupt {
|
||||
case InterruptNoise:
|
||||
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||
@@ -137,10 +166,15 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
||||
_ = 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
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the harvest attempt.
|
||||
roll := rand.IntN(20) + 1
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
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: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
|
||||
final: finalMsg,
|
||||
reason: stopOK,
|
||||
nextRoomType: next,
|
||||
}, nil
|
||||
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 —
|
||||
|
||||
Reference in New Issue
Block a user