Files
gogobee/internal/plugin/dnd_expedition_night.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

298 lines
8.5 KiB
Go
Raw Permalink 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"
"strings"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E2b — Wandering monster system & night phase resolution.
// Spec: gogobee_expedition_system.md §6.
//
// The check fires once per night during the 21:00 recap when the player
// has an active camp. Result is logged with a TwinBee narration and
// surfaced in the recap body. Combat resolution itself ("player wakes
// to combat") is deferred — expeditions don't yet host their own combat
// loop, so for E2b a triggered encounter persists as a NightEvents
// entry on the camp and a flavored "night" log line. The combat-link
// phase will read pending night encounters at !advance time.
// NightOutcome enumerates the d20 result buckets per §6.1.
type NightOutcome int
const (
NightOutcomePeaceful NightOutcome = iota
NightOutcomePassage
NightOutcomeMinor
NightOutcomeStandard
NightOutcomeElite
NightOutcomeAmbush
)
// NightCheck is the resolved roll + chosen monster (if any).
type NightCheck struct {
Outcome NightOutcome
Roll int // raw d20
Total int // roll + modifiers
ThreatMod int // §6.1 threat modifier
CampMod int // rough +3, fortified -4
ClassMod int // ranger -2 (wilderness)
MonsterID string // bestiary id of the encounter, if any
MonsterName string
Summary string // short factual line
Flavor string // I voice line, may be empty
ThreatBumped bool // signs-of-passage adds +2 threat
}
// Class-tag for ranger discount: §6.3 "while in a wilderness zone".
// We treat outdoor / forest / underdark / feywild zones as wilderness.
var wildernessZones = map[ZoneID]bool{
ZoneForestShadows: true,
ZoneSunkenTemple: true, // tidal exterior
ZoneUnderdark: true,
ZoneFeywildCrossing: true,
ZoneDragonsLair: true, // mountain
}
// resolveWanderingCheck rolls the d20 and applies modifiers per §6.16.3.
// charClass is the active character's class (used for ranger/rogue mods);
// pass "" if unknown. roll1d20 is injectable for testing.
func resolveWanderingCheck(e *Expedition, charClass DnDClass, roll1d20 func() int) NightCheck {
if e.Camp == nil || !e.Camp.Active {
return NightCheck{Outcome: NightOutcomePeaceful, Summary: "no camp — no night check"}
}
if roll1d20 == nil {
roll1d20 = func() int { return rand.IntN(20) + 1 }
}
r := roll1d20()
threatMod := 0
if e.ThreatLevel > 30 {
// §6.1: +1 per full 10 points above 30. Level 40 → +1; 35 → +0.
threatMod = (e.ThreatLevel - 30) / 10
}
// Babysit safe-rest: an active subscription gives Standard camps the
// fortified-tier night-risk reduction (the babysitter is watching).
effectiveCamp := e.Camp.Type
if effectiveCamp == CampTypeStandard && BabysitSafeRest(id.UserID(e.UserID)) {
effectiveCamp = CampTypeFortified
}
campMod := 0
switch effectiveCamp {
case CampTypeRough:
campMod = 3
case CampTypeFortified:
campMod = -4
case CampTypeBase:
campMod = -6
}
classMod := 0
if charClass == ClassRanger && wildernessZones[e.ZoneID] {
classMod = -2
}
total := r + threatMod + campMod + classMod
out := wanderingOutcome(total)
nc := NightCheck{
Outcome: out,
Roll: r,
Total: total,
ThreatMod: threatMod,
CampMod: campMod,
ClassMod: classMod,
}
switch out {
case NightOutcomePeaceful:
nc.Summary = "Peaceful night."
case NightOutcomePassage:
nc.Summary = "Signs of passage near camp; no encounter."
nc.ThreatBumped = true
case NightOutcomeMinor, NightOutcomeStandard, NightOutcomeElite, NightOutcomeAmbush:
mid, mname := pickWanderingMonster(e.ZoneID, out)
nc.MonsterID = mid
nc.MonsterName = mname
nc.Summary = fmt.Sprintf("%s — %s.", outcomeLabel(out), describeMonsterCount(out, mname))
}
nc.Flavor = wanderingFlavor(out, e.ThreatLevel, e.SiegeMode)
return nc
}
// wanderingOutcome buckets the modified total per §6.1.
func wanderingOutcome(total int) NightOutcome {
switch {
case total >= 20:
return NightOutcomeAmbush
case total >= 18:
return NightOutcomeElite
case total >= 15:
return NightOutcomeStandard
case total >= 11:
return NightOutcomeMinor
case total >= 6:
return NightOutcomePassage
default:
return NightOutcomePeaceful
}
}
func outcomeLabel(o NightOutcome) string {
switch o {
case NightOutcomePeaceful:
return "Peaceful night"
case NightOutcomePassage:
return "Signs of passage"
case NightOutcomeMinor:
return "Minor encounter"
case NightOutcomeStandard:
return "Standard encounter"
case NightOutcomeElite:
return "Elite encounter"
case NightOutcomeAmbush:
return "Ambush"
}
return "Night check"
}
func describeMonsterCount(o NightOutcome, name string) string {
switch o {
case NightOutcomeMinor:
return fmt.Sprintf("12 %s wake you", pluralize(name))
case NightOutcomeStandard:
return fmt.Sprintf("a group of %s closes in", pluralize(name))
case NightOutcomeElite:
return fmt.Sprintf("an elite %s steps out of the dark", name)
case NightOutcomeAmbush:
return fmt.Sprintf("an elite %s catches you mid-sleep — surprise round", name)
}
return name
}
func pluralize(name string) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, "s"), strings.HasSuffix(lower, "x"), strings.HasSuffix(lower, "z"):
return name + "es"
default:
return name + "s"
}
}
// pickWanderingMonster pulls a roster entry from the zone, biased by elite
// flag for elite/ambush outcomes. Returns "", "" if the zone has no roster.
func pickWanderingMonster(zoneID ZoneID, o NightOutcome) (string, string) {
zone, ok := getZone(zoneID)
if !ok || len(zone.Enemies) == 0 {
return "", ""
}
wantElite := o == NightOutcomeElite || o == NightOutcomeAmbush
var pool []ZoneEnemy
for _, en := range zone.Enemies {
if en.IsElite == wantElite {
pool = append(pool, en)
}
}
if len(pool) == 0 {
// Fallback: any enemy regardless of elite flag.
pool = zone.Enemies
}
// Weighted pick by SpawnWeight; default 5 if zero.
totalW := 0
for _, en := range pool {
w := en.SpawnWeight
if w <= 0 {
w = 5
}
totalW += w
}
pickW := rand.IntN(totalW) + 1
cum := 0
for _, en := range pool {
w := en.SpawnWeight
if w <= 0 {
w = 5
}
cum += w
if pickW <= cum {
if m, ok := dndBestiary[en.BestiaryID]; ok {
return en.BestiaryID, m.Name
}
return en.BestiaryID, en.BestiaryID
}
}
return "", ""
}
// wanderingFlavor picks a TwinBee line from the existing ThreatClock pools
// when the band's voice fits the outcome; falls back to "" so the recap
// renders just the factual summary.
func wanderingFlavor(o NightOutcome, threat int, siege bool) string {
if o == NightOutcomePeaceful || o == NightOutcomePassage {
return ""
}
band := threatBandFor(threat, siege)
switch band {
case ThreatBandSiege:
return flavor.Pick(flavor.ThreatClockSiege)
case ThreatBandHostile:
return flavor.Pick(flavor.ThreatClockHostile)
case ThreatBandAlert:
return flavor.Pick(flavor.ThreatClockAlert)
case ThreatBandStirring:
return flavor.Pick(flavor.ThreatClockStirring)
}
return ""
}
// renderNightCheck formats a recap-bottom block for the wandering result.
func renderNightCheck(nc NightCheck) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("🌙 **Night phase — %s**\n", outcomeLabel(nc.Outcome)))
if nc.Summary != "" {
b.WriteString(nc.Summary + "\n")
}
b.WriteString(fmt.Sprintf("_d20=%d, total=%d (threat%+d, camp%+d, class%+d)_\n",
nc.Roll, nc.Total, nc.ThreatMod, nc.CampMod, nc.ClassMod))
if nc.Flavor != "" {
b.WriteString("\n" + nc.Flavor + "\n")
}
if nc.Outcome == NightOutcomeMinor || nc.Outcome == NightOutcomeStandard ||
nc.Outcome == NightOutcomeElite || nc.Outcome == NightOutcomeAmbush {
b.WriteString("\n_Encounter pending — resolves at your next `!advance`._\n")
}
return b.String()
}
// processNightCheck applies side-effects: log entry, threat bump for
// signs-of-passage, persist NightEvents on the camp.
func processNightCheck(e *Expedition, nc NightCheck) error {
summary := nc.Summary
if nc.MonsterName != "" {
summary = fmt.Sprintf("%s [d20=%d, total=%d, mods: threat%+d camp%+d class%+d]",
summary, nc.Roll, nc.Total, nc.ThreatMod, nc.CampMod, nc.ClassMod)
} else {
summary = fmt.Sprintf("%s [d20=%d, total=%d]", summary, nc.Roll, nc.Total)
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "night", summary, nc.Flavor); err != nil {
return err
}
if nc.ThreatBumped {
_ = applyThreatDelta(e.ID, 2, "signs of passage near camp")
}
if e.Camp != nil {
e.Camp.NightEvents = append(e.Camp.NightEvents, summary)
if err := updateCamp(e.ID, e.Camp); err != nil {
return err
}
}
return nil
}