Files
gogobee/internal/plugin/dnd_expedition_autopilot_harvest.go
prosolis 41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
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>
2026-05-16 12:40:17 -07:00

307 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 13, success rate 5080%) 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))
}