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.
|
// 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):
|
// Design (Josie semantics, settled 2026-05-17):
|
||||||
// - All applicable actions auto-run on every walked room. Class- and
|
// - 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
|
// - Noise interrupts apply threat++ and continue the per-charge loop
|
||||||
// (parity with manual). Standard/Elite/Patrol combat interrupts
|
// (parity with manual). Standard/Elite/Patrol combat interrupts
|
||||||
// hard-stop the walk: stopEnded on death, stopHarvestCombat otherwise.
|
// 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.
|
// - No SU surcharge — manual !harvest is free; autopilot matches.
|
||||||
// - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally
|
// - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally
|
||||||
// across all rooms.
|
// across all rooms.
|
||||||
@@ -57,10 +65,12 @@ var allHarvestActions = []HarvestAction{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// autoHarvestRoom runs a single auto-harvest pass on the player's current
|
// autoHarvestRoom runs a single auto-harvest pass on the player's current
|
||||||
// room. Called by expeditionCmdRun between walked rooms. Does not advance
|
// room. exp may be nil for standalone zone runs (no expedition); in that
|
||||||
// the room; only resolves nodes already at the player's feet.
|
// 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(
|
func (p *AdventurePlugin) autoHarvestRoom(
|
||||||
userID id.UserID, exp *Expedition, char *DnDCharacter,
|
userID id.UserID, run *DungeonRun, char *DnDCharacter, exp *Expedition,
|
||||||
) (autoHarvestResult, error) {
|
) (autoHarvestResult, error) {
|
||||||
res := autoHarvestResult{
|
res := autoHarvestResult{
|
||||||
Summary: autoHarvestSummary{
|
Summary: autoHarvestSummary{
|
||||||
@@ -68,32 +78,44 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
Names: map[string]string{},
|
Names: map[string]string{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if exp == nil || exp.RunID == "" {
|
if run == nil {
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
run, err := getZoneRun(exp.RunID)
|
|
||||||
if err != nil || run == nil {
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
if !currentRoomCleared(run) {
|
if !currentRoomCleared(run) {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
zoneID := run.ZoneID
|
||||||
|
if exp != nil {
|
||||||
|
zoneID = exp.ZoneID
|
||||||
|
}
|
||||||
|
|
||||||
nodeID := harvestNodeIDFor(run)
|
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 {
|
if len(nodes) == 0 {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
zone, _ := getZone(exp.ZoneID)
|
zone, _ := getZone(zoneID)
|
||||||
persistNodes := false
|
persistNodes := false
|
||||||
|
|
||||||
for _, action := range allHarvestActions {
|
for _, action := range allHarvestActions {
|
||||||
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
|
if action == HarvestFish && !isFishingZone(zoneID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
if exp != nil {
|
||||||
continue
|
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One pass per action. Iterate every eligible node (not just the
|
// One pass per action. Iterate every eligible node (not just the
|
||||||
@@ -103,43 +125,55 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
if n.CurrentCharges <= 0 {
|
if n.CurrentCharges <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rdef, ok := FindZoneResource(exp.ZoneID, n.ResourceID)
|
rdef, ok := FindZoneResource(zoneID, n.ResourceID)
|
||||||
if !ok || rdef.Action != action {
|
if !ok || rdef.Action != action {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
|
if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rdef.RequiresKill != "" && !regionKillsContains(exp, rdef.RequiresKill) {
|
if rdef.RequiresKill != "" {
|
||||||
continue
|
if exp == nil || !regionKillsContains(exp, rdef.RequiresKill) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if rdef.ConditionalEvent != "" && !regionEventActive(exp, rdef.ConditionalEvent) {
|
if rdef.ConditionalEvent != "" {
|
||||||
continue
|
if exp == nil || !regionEventActive(exp, rdef.ConditionalEvent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Josie semantics: swing exactly once per remaining charge.
|
// Josie semantics: swing exactly once per remaining charge.
|
||||||
// Each swing consumes a charge regardless of outcome.
|
// Each swing consumes a charge regardless of outcome.
|
||||||
for n.CurrentCharges > 0 {
|
for n.CurrentCharges > 0 {
|
||||||
// Interrupt roll, same model as handleHarvestCmd.
|
// Interrupt roll, same model as handleHarvestCmd —
|
||||||
interrupt, intTotal := resolveCombatInterrupt(
|
// expedition-only. Standalone zone runs already have a
|
||||||
exp.ThreatLevel, int(zone.Tier), char.Class, exp.ZoneID, nil)
|
// patrol mechanism in the advance pipeline.
|
||||||
switch interrupt {
|
if exp != nil {
|
||||||
case InterruptNoise:
|
interrupt, intTotal := resolveCombatInterrupt(
|
||||||
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
exp.ThreatLevel, int(zone.Tier), char.Class, zoneID, nil)
|
||||||
res.Summary.NoiseInts++
|
switch interrupt {
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
case InterruptNoise:
|
||||||
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||||
// fall through to the d20 roll, same as manual harvest.
|
res.Summary.NoiseInts++
|
||||||
case InterruptStandard, InterruptElite, InterruptPatrol:
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||||
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
||||||
res.CombatNarr = body
|
// fall through to the d20 roll, same as manual harvest.
|
||||||
res.PlayerEnded = ended
|
case InterruptStandard, InterruptElite, InterruptPatrol:
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
||||||
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
|
res.CombatNarr = body
|
||||||
if persistNodes {
|
res.PlayerEnded = ended
|
||||||
_ = saveHarvestNodes(exp, nodeID, nodes)
|
_ = 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.
|
// Resolve the harvest attempt.
|
||||||
@@ -151,7 +185,10 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
total := roll + mod
|
total := roll + mod
|
||||||
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
|
dcDelta := 0
|
||||||
|
if exp != nil {
|
||||||
|
dcDelta, _ = zoneConditionHarvestDCMod(exp, action)
|
||||||
|
}
|
||||||
effectiveDC := rdef.DC + dcDelta
|
effectiveDC := rdef.DC + dcDelta
|
||||||
outcome := resolveHarvestOutcome(total, effectiveDC)
|
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||||
@@ -166,7 +203,7 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
grantedQty = rand.IntN(3) + 1
|
grantedQty = rand.IntN(3) + 1
|
||||||
case OutcomeRich:
|
case OutcomeRich:
|
||||||
grantedQty = rand.IntN(3) + 1
|
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)
|
_ = grantHarvestYield(userID, *bonus, 1)
|
||||||
res.Summary.Yields[bonus.ID] += 1
|
res.Summary.Yields[bonus.ID] += 1
|
||||||
res.Summary.Names[bonus.ID] = bonus.Name
|
res.Summary.Names[bonus.ID] = bonus.Name
|
||||||
@@ -181,17 +218,25 @@ func (p *AdventurePlugin) autoHarvestRoom(
|
|||||||
n.CurrentCharges--
|
n.CurrentCharges--
|
||||||
persistNodes = true
|
persistNodes = true
|
||||||
|
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
if exp != nil {
|
||||||
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
|
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 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",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
fmt.Sprintf("autopilot persistence error: %v", err), "")
|
fmt.Sprintf("autopilot persistence error: %v", perr), "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
|
|||||||
@@ -625,42 +625,42 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2 — auto-harvest the room we just walked into. Aggregates
|
// Phase 2 — auto-harvest the room we just walked into.
|
||||||
// into walkYields/walkNames; per-room footer goes straight into
|
// advanceOnceWithOpts now runs the pass inline and surfaces the
|
||||||
// the stream. Combat interrupts hard-stop the walk with a
|
// result via res.harvest + res.harvestFooter. The "✓ cleared /
|
||||||
// dedicated stop reason; Josie semantics mean rarity no longer
|
// next room" line in res.final already has the per-room footer
|
||||||
// pauses the autopilot.
|
// appended (and the combat-interrupt narration if one fired);
|
||||||
char, cerr := LoadDnDCharacter(ctx.Sender)
|
// we still need to aggregate into walkYields/walkNames and to
|
||||||
if cerr != nil || char == nil {
|
// split out the harvest-combat path so the walk-end footer/tally
|
||||||
continue
|
// rendering matches the pre-H2 behavior.
|
||||||
}
|
hr := res.harvest
|
||||||
hr, herr := p.autoHarvestRoom(ctx.Sender, exp, char)
|
|
||||||
if herr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range hr.Summary.Yields {
|
for k, v := range hr.Summary.Yields {
|
||||||
walkYields[k] += v
|
walkYields[k] += v
|
||||||
walkNames[k] = hr.Summary.Names[k]
|
walkNames[k] = hr.Summary.Names[k]
|
||||||
}
|
}
|
||||||
if footer := renderAutoHarvestFooter(hr.Summary); footer != "" {
|
|
||||||
stream = append(stream, footer)
|
|
||||||
}
|
|
||||||
if hr.CombatNarr != "" {
|
if hr.CombatNarr != "" {
|
||||||
// A harvest interrupt fired combat. The combat narration
|
// A harvest interrupt fired combat. res.final already
|
||||||
// becomes the final block; reason classifies death vs survive.
|
// includes the ✓-cleared block, the harvest footer, and the
|
||||||
|
// combat narration; just attach the walk footer/tally and
|
||||||
|
// stop.
|
||||||
r := stopHarvestCombat
|
r := stopHarvestCombat
|
||||||
if hr.PlayerEnded {
|
if hr.PlayerEnded {
|
||||||
r = stopEnded
|
r = stopEnded
|
||||||
}
|
}
|
||||||
footer := autopilotFooter(r, rooms)
|
footer := autopilotFooter(r, rooms)
|
||||||
finalMsg = hr.CombatNarr
|
finalMsg = res.final
|
||||||
if footer != "" {
|
if footer != "" {
|
||||||
finalMsg += "\n\n" + footer
|
finalMsg += "\n\n" + footer
|
||||||
}
|
}
|
||||||
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
|
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
|
||||||
finalMsg += "\n\n" + tally
|
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
|
reason = r
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,6 +404,12 @@ type advanceResult struct {
|
|||||||
// re-emit the Boss/Elite doorway gate (and produce a duplicate
|
// re-emit the Boss/Elite doorway gate (and produce a duplicate
|
||||||
// "Room X/Y — Boss" line in the same DM).
|
// "Room X/Y — Boss" line in the same DM).
|
||||||
nextRoomType RoomType
|
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,
|
// zoneCmdAdvance resolves the room the player is currently standing in,
|
||||||
@@ -622,14 +628,65 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
|
|||||||
reason: stopFork,
|
reason: stopFork,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return advanceResult{
|
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next)
|
||||||
preStream: preStream,
|
|
||||||
intro: intro,
|
// H2 — auto-harvest the room the player just walked into. Only fires
|
||||||
phases: phases,
|
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via
|
||||||
final: p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next),
|
// currentRoomCleared / autoHarvestRoom's no-op when there's nothing
|
||||||
reason: stopOK,
|
// to harvest). Uses standalone storage when there's no active
|
||||||
nextRoomType: next,
|
// expedition, expedition region_state when there is.
|
||||||
}, nil
|
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 —
|
// formatNextRoomMessage builds the "✓ Cleared room X. ... Room Y/N —
|
||||||
|
|||||||
Reference in New Issue
Block a user