mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Tedium-removal pass driven by live play feedback. Three big threads: Expedition autopilot Phase 4 — background auto-run + harvest-until-dry: - New expeditionAutoRunTicker walks active expeditions every 15min (5min tick, per-expedition CAS on new last_autorun_at column). Skips combat sessions, briefing/recap quiet windows, expeditions <30min old. - Walks up to 3 rooms/tick with compact narration; suppresses DMs when 0 rooms walked or just hitting the per-tick cap (no "stretch complete" filler). Player only hears from the bot when a real decision is needed. - Compact mode: one-line combat narration for trash/elite, auto-resolves elite doorways via the forward-sim engine (boss still pauses). Threaded via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom. - Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8 attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses. - Ambient ticker: anti-repeat by Kind via new last_ambient_kind column (avoids two pack_rat DMs in a row when the pool only has 6 lines). - !expedition go <n> now routes to the fork-choice handler when active + numeric; fork footer rewritten to suggest !expedition go instead of !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action hint by next room type and autopilot loop breaks via new nextRoomType field so "Room X/Y — Boss" doesn't double-print with contradictory hints (!zone advance vs !fight). - runAutopilotWalk extracted from expeditionCmdRun so foreground and background share the loop body. Rogue Sneak Attack actually exists now: - Audit found the rogue's "Sneak Attack" passive was AutoCritFirst + 5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this with a small flat buff; the class's defining mechanic never matched its tooltip or 5e identity. - Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in combat_primitives.go (same lane as DivineStrikePerHit). Scales with level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20. - AutoCritFirst + 5% rider retained as bonuses on top of working sneak attack — preserves the opener-burst feel. - L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes from "16 rounds to kill, die in 8" to winnable. TwinBee voice sweep (Phase B3): - ~530 line changes across 24 files replacing third-person "TwinBee notes/files/tracks/respects/..." constructions with first-person inside flavor string literals. Code identifiers (TwinBeeLine, twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names (TwinBee's Bell), achievements, and game-title references in fun.go (Konami TwinBee ship) left intact. - Catches a real bug: Valdris fight rendered "TwinBee marks the Legendary Resistance" mid-combat in third person, contradicting the Phase B2 convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
10 KiB
Go
307 lines
10 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand/v2"
|
||
"sort"
|
||
"strings"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 2 of expedition autopilot — auto-harvest on room entry.
|
||
//
|
||
// Design (settled 2026-05-14, harvest-until-dry added 2026-05-16):
|
||
// - All applicable actions auto-run on every walked room. Class- and
|
||
// kill-gated nodes filter themselves out via the existing per-resource
|
||
// restrictions.
|
||
// - Rare+ nodes are NOT auto-attempted. If any sit available in the
|
||
// room, autopilot stops with stopRareNode so the player can spend the
|
||
// attempt deliberately.
|
||
// - Each eligible (Common/Uncommon) node is ground until it runs dry or
|
||
// until autoHarvestPerNodeAttempts attempts have been spent — whichever
|
||
// comes first. Failed rolls re-roll without depleting charges, same
|
||
// as a manual player retrying !forage/!scavenge/etc. The cap stops
|
||
// the walk from getting stuck on a hard-DC node forever.
|
||
// - Noise interrupts apply threat++ and continue (parity with manual).
|
||
// - Standard/Elite/Patrol combat interrupts hard-stop the walk:
|
||
// stopEnded on death, stopHarvestCombat otherwise.
|
||
// - No SU surcharge — manual !harvest is free; autopilot matches.
|
||
// - Per-room footer ("+2 Iron, +1 Sage; 1 fail") + a walk-end tally
|
||
// across all rooms.
|
||
|
||
// autoHarvestPerNodeAttempts bounds how many times autopilot will swing
|
||
// at a single node before giving up for this visit. Sized to handle a
|
||
// typical Common/Uncommon node (charges 1–3, success rate 50–80%) with
|
||
// margin, while keeping a DC-20 outlier from spinning indefinitely.
|
||
const autoHarvestPerNodeAttempts = 8
|
||
|
||
// autoHarvestSummary holds the aggregated outcome for one room's
|
||
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
||
// also folds it into a walk-wide tally for the closing block.
|
||
type autoHarvestSummary struct {
|
||
Yields map[string]int // resourceID -> total qty granted
|
||
Names map[string]string // resourceID -> display name (capture once)
|
||
Fails int
|
||
NoiseInts int
|
||
}
|
||
|
||
// autoHarvestResult is what autoHarvestRoom returns to the autopilot loop.
|
||
type autoHarvestResult struct {
|
||
Summary autoHarvestSummary
|
||
RarePending []ZoneResource // non-empty = at least one Rare+ node sits in the room
|
||
CombatNarr string // non-empty = a combat interrupt fired during the pass
|
||
PlayerEnded bool // true = combat killed the player
|
||
}
|
||
|
||
// allHarvestActions lists every action autopilot will try per room. Order
|
||
// matters only for footer readability — class- and gate-restricted nodes
|
||
// drop out via the existing filters.
|
||
var allHarvestActions = []HarvestAction{
|
||
HarvestForage,
|
||
HarvestScavenge,
|
||
HarvestMine,
|
||
HarvestFish,
|
||
HarvestEssence,
|
||
HarvestCommune,
|
||
}
|
||
|
||
// isRarePlus reports whether a resource's rarity should pause autopilot.
|
||
// Common/Uncommon are auto-attempted; Rare and above stop the walk.
|
||
func isRarePlus(r DnDRarity) bool {
|
||
switch r {
|
||
case RarityRare, RarityEpic, RarityVeryRare, RarityLegendary:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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.
|
||
func (p *AdventurePlugin) autoHarvestRoom(
|
||
userID id.UserID, exp *Expedition, char *DnDCharacter,
|
||
) (autoHarvestResult, error) {
|
||
res := autoHarvestResult{
|
||
Summary: autoHarvestSummary{
|
||
Yields: map[string]int{},
|
||
Names: map[string]string{},
|
||
},
|
||
}
|
||
if exp == nil || exp.RunID == "" {
|
||
return res, nil
|
||
}
|
||
run, err := getZoneRun(exp.RunID)
|
||
if err != nil || run == nil {
|
||
return res, nil
|
||
}
|
||
if !currentRoomCleared(run) {
|
||
return res, nil
|
||
}
|
||
|
||
nodeID := harvestNodeIDFor(run)
|
||
nodes := loadHarvestNodes(exp, nodeID)
|
||
if len(nodes) == 0 {
|
||
return res, nil
|
||
}
|
||
|
||
zone, _ := getZone(exp.ZoneID)
|
||
persistNodes := false
|
||
|
||
for _, action := range allHarvestActions {
|
||
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
|
||
continue
|
||
}
|
||
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.
|
||
for i := range nodes {
|
||
n := &nodes[i]
|
||
if n.CurrentCharges <= 0 {
|
||
continue
|
||
}
|
||
rdef, ok := FindZoneResource(exp.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.ConditionalEvent != "" && !regionEventActive(exp, rdef.ConditionalEvent) {
|
||
continue
|
||
}
|
||
|
||
if isRarePlus(rdef.Rarity) {
|
||
// Defer the decision to the player; don't consume the attempt.
|
||
res.RarePending = append(res.RarePending, rdef)
|
||
continue
|
||
}
|
||
|
||
// Grind this node until it's dry or until the per-node attempt
|
||
// cap fires. A failed roll re-rolls (mirrors a manual player
|
||
// pressing the harvest command again); only successful pulls
|
||
// decrement charges.
|
||
for attempt := 0; attempt < autoHarvestPerNodeAttempts && n.CurrentCharges > 0; attempt++ {
|
||
// 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)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// Resolve the harvest attempt.
|
||
roll := rand.IntN(20) + 1
|
||
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||
if action == HarvestFish {
|
||
if adv, _ := loadAdvCharacter(userID); adv != nil {
|
||
mod += fishingSkillBonus(adv.FishingSkill)
|
||
}
|
||
}
|
||
total := roll + mod
|
||
dcDelta, _ := zoneConditionHarvestDCMod(exp, action)
|
||
effectiveDC := rdef.DC + dcDelta
|
||
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||
|
||
var grantedQty int
|
||
switch outcome {
|
||
case OutcomeNothing:
|
||
res.Summary.Fails++
|
||
case OutcomeCommon:
|
||
grantedQty = 1
|
||
case OutcomeStandard:
|
||
grantedQty = rand.IntN(3) + 1
|
||
case OutcomeRich:
|
||
grantedQty = rand.IntN(3) + 1
|
||
if bonus := pickRichBonus(exp.ZoneID, rdef.ID); bonus != nil {
|
||
_ = grantHarvestYield(userID, *bonus, 1)
|
||
res.Summary.Yields[bonus.ID] += 1
|
||
res.Summary.Names[bonus.ID] = bonus.Name
|
||
}
|
||
}
|
||
if grantedQty > 0 {
|
||
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||
res.Summary.Yields[rdef.ID] += grantedQty
|
||
res.Summary.Names[rdef.ID] = rdef.Name
|
||
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 persistNodes {
|
||
if err := saveHarvestNodes(exp, nodeID, nodes); err != nil {
|
||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||
fmt.Sprintf("autopilot persistence error: %v", err), "")
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// renderAutoHarvestFooter formats one room's pass as a single compact
|
||
// line: "_+2 Scrap Iron, +1 Shadow Herb · 1 fail · 1 close call._".
|
||
// Empty string means there's nothing worth showing.
|
||
func renderAutoHarvestFooter(s autoHarvestSummary) string {
|
||
if len(s.Yields) == 0 && s.Fails == 0 && s.NoiseInts == 0 {
|
||
return ""
|
||
}
|
||
parts := []string{}
|
||
// Stable ordering — sort yield keys so test output is deterministic
|
||
// and the footer reads consistently across rooms.
|
||
keys := make([]string, 0, len(s.Yields))
|
||
for k := range s.Yields {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, k := range keys {
|
||
parts = append(parts, fmt.Sprintf("+%d %s", s.Yields[k], s.Names[k]))
|
||
}
|
||
if s.Fails > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d fail", s.Fails))
|
||
}
|
||
if s.NoiseInts > 0 {
|
||
noun := "close call"
|
||
if s.NoiseInts > 1 {
|
||
noun = "close calls"
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%d %s", s.NoiseInts, noun))
|
||
}
|
||
return "_🎒 " + strings.Join(parts, " · ") + "._"
|
||
}
|
||
|
||
// renderWalkTally formats the cross-room cumulative haul for the walk's
|
||
// final block. Empty string when nothing was gathered.
|
||
func renderWalkTally(yields map[string]int, names map[string]string) string {
|
||
if len(yields) == 0 {
|
||
return ""
|
||
}
|
||
keys := make([]string, 0, len(yields))
|
||
for k := range yields {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
var b strings.Builder
|
||
b.WriteString("**Walk haul:** ")
|
||
for i, k := range keys {
|
||
if i > 0 {
|
||
b.WriteString(", ")
|
||
}
|
||
b.WriteString(fmt.Sprintf("%d %s", yields[k], names[k]))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderRarePendingFooter announces which Rare+ nodes triggered the
|
||
// autopilot pause. Used by the stop reason footer in expeditionCmdRun.
|
||
func renderRarePendingFooter(pending []ZoneResource) string {
|
||
if len(pending) == 0 {
|
||
return ""
|
||
}
|
||
// De-dup by ID (a single resource can have multiple charges across
|
||
// nodes; we want one mention).
|
||
seen := map[string]bool{}
|
||
var names []string
|
||
for _, r := range pending {
|
||
if seen[r.ID] {
|
||
continue
|
||
}
|
||
seen[r.ID] = true
|
||
names = append(names, fmt.Sprintf("**%s** (%s)", r.Name, string(r.Rarity)))
|
||
}
|
||
sort.Strings(names)
|
||
verb := "is"
|
||
if len(names) > 1 {
|
||
verb = "are"
|
||
}
|
||
return fmt.Sprintf("⏸ **Autopilot paused — rare yield nearby.** %s %s sitting in this room. `!%s` to take the shot, or `!expedition run` to push past.",
|
||
strings.Join(names, ", "), verb,
|
||
// suggest the action of the first pending resource as a hint.
|
||
string(pending[0].Action))
|
||
}
|