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

517 lines
18 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 (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E1d — real-time day cycle engine.
// Spec: gogobee_expedition_system.md §3, §12.
//
// Two tickers run at 1-minute granularity: briefings fire once per UTC day
// at 06:00, recaps once per UTC day at 21:00. Each tick walks all active
// expeditions and fires for any whose last_briefing_at / last_recap_at is
// stale relative to today's threshold. Per-expedition gating (rather than
// global JobCompleted) lets a player who started mid-day still receive a
// briefing the next morning even if some other expedition already triggered
// the global ticker.
//
// Day rollover: the briefing also bumps current_day by one and applies one
// day's supply burn. Day 1's briefing fires the first morning *after* the
// expedition begins (so an expedition started at 23:00 doesn't immediately
// flip to Day 2 at midnight; the spec treats Day 1 as the calendar day
// of departure).
const (
expeditionBriefingHour = 6
expeditionRecapHour = 21
)
// expeditionBriefingTicker — 06:00 UTC daily briefing.
func (p *AdventurePlugin) expeditionBriefingTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now().UTC()
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
continue
}
p.fireExpeditionBriefings(now)
}
}
}
// expeditionRecapTicker — 21:00 UTC daily recap.
func (p *AdventurePlugin) expeditionRecapTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now().UTC()
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
continue
}
p.fireExpeditionRecaps(now)
}
}
}
// fireExpeditionBriefings finds every active expedition without a briefing
// for today's UTC date and fires one per. Public-on-package for testing.
func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) {
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
exps, err := loadExpeditionsNeedingBriefing(threshold)
if err != nil {
slog.Error("expedition: load briefings", "err", err)
return
}
for _, e := range exps {
// Don't roll the expedition day forward into a live turn-based fight:
// an active combat session locks the run, and the briefing burns
// supply / advances the day / processes overnight camp. last_briefing_at
// stays stale, so the rollover simply lands on the next 06:00 tick —
// the expedition holds on its current day for one extra real day, which
// is mild and player-favorable versus mutating a locked run.
if hasActiveCombatSession(id.UserID(e.UserID)) {
slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
continue
}
if err := p.deliverBriefing(e, now); err != nil {
slog.Error("expedition: briefing", "expedition", e.ID, "err", err)
}
}
}
// fireExpeditionRecaps finds every active expedition without a recap for
// today's UTC date and fires one per.
func (p *AdventurePlugin) fireExpeditionRecaps(now time.Time) {
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionRecapHour, 0, 0, 0, time.UTC)
exps, err := loadExpeditionsNeedingRecap(threshold)
if err != nil {
slog.Error("expedition: load recaps", "err", err)
return
}
for _, e := range exps {
// Same guard as briefings: the recap runs the night wandering check,
// which can bump threat. Don't mutate a run locked by a live fight —
// last_recap_at stays stale and the recap lands on the next tick.
if hasActiveCombatSession(id.UserID(e.UserID)) {
slog.Info("expedition: recap deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
continue
}
if err := p.deliverRecap(e, now); err != nil {
slog.Error("expedition: recap", "expedition", e.ID, "err", err)
}
}
}
// loadExpeditionsNeedingBriefing — active expeditions whose last_briefing_at
// is NULL or strictly before today's 06:00 UTC.
func loadExpeditionsNeedingBriefing(threshold time.Time) ([]*Expedition, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, zone_id, run_id, status,
start_date, current_day, current_region, boss_defeated,
supplies_json, camp_json, threat_level, threat_siege,
threat_events, temporal_stack, region_state,
xp_earned, coins_earned, gm_mood,
last_briefing_at, last_recap_at, last_ambient_kind,
last_activity, completed_at
FROM dnd_expedition
WHERE status = 'active'
AND start_date < ?
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
threshold, threshold)
if err != nil {
return nil, err
}
defer rows.Close()
return scanExpeditionRows(rows)
}
// loadExpeditionsNeedingRecap — same but for the 21:00 threshold.
func loadExpeditionsNeedingRecap(threshold time.Time) ([]*Expedition, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, zone_id, run_id, status,
start_date, current_day, current_region, boss_defeated,
supplies_json, camp_json, threat_level, threat_siege,
threat_events, temporal_stack, region_state,
xp_earned, coins_earned, gm_mood,
last_briefing_at, last_recap_at, last_ambient_kind,
last_activity, completed_at
FROM dnd_expedition
WHERE status = 'active'
AND (last_recap_at IS NULL OR last_recap_at < ?)`, threshold)
if err != nil {
return nil, err
}
defer rows.Close()
return scanExpeditionRows(rows)
}
func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
var out []*Expedition
for rows.Next() {
e, err := scanExpedition(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// deliverBriefing rolls a day forward, applies supply burn, posts the
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
//
// Idempotency: the first thing we do is an atomic compare-and-set on
// last_briefing_at. If another invocation (clock skew, restart, double
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
// without re-applying supply burn / day++ / threat drift.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
res, err := db.Get().Exec(`
UPDATE dnd_expedition
SET last_briefing_at = ?,
last_activity = ?
WHERE expedition_id = ?
AND status = 'active'
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
now, now, e.ID, threshold)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil // already delivered for this day
}
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
// can override the entire burn calculation with a fixed multiplier
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// Advance day + supply burn happen together at the morning rollover.
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
// Phase 5-B: temporal overrides bypass applyDailyBurn, so apply
// the same global burn-rate multiplier explicitly here. Without
// this, tidal/unraveling days would burn at pre-Phase-5-B rates
// while normal days burn at half — an inconsistency the user
// would feel as "tidal days are now disproportionately harsh."
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
return err
}
if err := advanceExpeditionDay(e.ID); err != nil {
return err
}
e.Supplies = newSupplies
e.CurrentDay++
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
// the camp after applying. Run before threat drift so a fortified
// camp's 5 lands first and a same-day +3 drift can't push back over
// a threshold the rest just dropped.
restSummary := processOvernightCamp(e)
if restSummary != "" {
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
e.ThreatLevel = fresh.ThreatLevel
e.SiegeMode = fresh.SiegeMode
e.Camp = fresh.Camp
}
}
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
// boss kill. May cross a threshold and append a flavor-bearing log.
if _, _, err := applyDailyThreatDrift(e); err != nil {
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
}
// E3: zone temporal events post-rollover narration (after the day
// has advanced, so e.CurrentDay reflects the new day).
temporalLines := applyZoneTemporalPostRollover(e)
// §4.3: starvation triggers forced extraction. With no CON tracking
// in this layer, the briefing-time check is the practical equivalent
// of "CON reaches 0" — a starvation morning means the player can't
// reasonably press on. Apply the §10.2 coin tax and flip status.
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
e.Status = ExpeditionStatusAbandoned
line := flavor.Pick(flavor.ExtractionForced)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
"forced extraction: starvation", line)
}
// E5b: if a temporal event (or starvation above) forced extraction,
// apply the §10.2 coin tax. The temporal layer flips the row to
// 'abandoned'; the cycle holds the euro handle to do the debit.
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
if tax > 0 {
p.euro.Debit(id.UserID(e.UserID), float64(tax),
"forced extraction tax")
}
}
// E6b: sample today's threat into RegionState["max_threat_seen"] before
// any milestone check reads it; then award daily milestones reached by
// the new day count (First Night day 2, Week One day 8, Two Weeks day 15).
_ = recordMaxThreat(e)
milestoneLines := p.checkDailyMilestones(e)
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if restSummary != "" {
body += "\n💤 _" + restSummary + "_\n"
}
for _, tl := range temporalLines {
body += "\n🌀 " + tl + "\n"
}
for _, ml := range milestoneLines {
body += "\n" + ml
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
return err
}
return nil
}
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
// last_recap_at. No supply burn here — that's the briefing's job.
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionRecapHour, 0, 0, 0, time.UTC)
res, err := db.Get().Exec(`
UPDATE dnd_expedition
SET last_recap_at = ?,
last_activity = ?
WHERE expedition_id = ?
AND status = 'active'
AND (last_recap_at IS NULL OR last_recap_at < ?)`,
now, now, e.ID, threshold)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
// E2b: night phase wandering check fires before the recap so its
// outcome is part of today's log when the recap renders.
var night *NightCheck
if e.Camp != nil && e.Camp.Active {
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
var charClass DnDClass
if c != nil {
charClass = c.Class
}
nc := resolveWanderingCheck(e, charClass, nil)
if err := processNightCheck(e, nc); err != nil {
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
}
night = &nc
// §7.4: Feywild double-day fires an extra wandering check.
if e.ZoneID == ZoneFeywildCrossing {
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
extra := resolveWanderingCheck(e, charClass, nil)
if err := processNightCheck(e, extra); err != nil {
slog.Warn("expedition: feywild extra night check", "expedition", e.ID, "err", err)
}
}
}
// Refresh in-memory threat after possible signs-of-passage bump.
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
e.ThreatLevel = fresh.ThreatLevel
e.SiegeMode = fresh.SiegeMode
}
}
dayEntries, err := dayLogEntries(e.ID, e.CurrentDay)
if err != nil {
return err
}
line := pickEveningRecap(e, dayEntries)
body := renderEveningRecap(e, line, dayEntries)
if night != nil {
body += "\n" + renderNightCheck(*night)
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
}
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
return err
}
return nil
}
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
// 14, 21 each have their own pool; everything else uses the generic pool.
func pickMorningBriefing(day int) string {
var pool []string
switch day {
case 1:
pool = flavor.MorningBriefingDay1
case 3:
pool = flavor.MorningBriefingDay3
case 7:
pool = flavor.MorningBriefingDay7
case 14:
pool = flavor.MorningBriefingDay14
case 21:
pool = flavor.MorningBriefingDay21
default:
pool = flavor.MorningBriefingGeneric
}
if line := flavor.Pick(pool); line != "" {
return line
}
return flavor.Pick(flavor.MorningBriefingGeneric)
}
// pickEveningRecap chooses a recap line based on what happened today.
// Boss kill > nothing-happened > generic. Close-call detection isn't wired
// in E1d (no HP-delta tracking yet); deferred to E2 alongside the threat
// clock combat hooks.
func pickEveningRecap(e *Expedition, today []ExpeditionEntry) string {
bossKilled := false
combatCount := 0
for _, en := range today {
if en.Type == "combat" {
combatCount++
}
if en.Type == "boss" || strings.Contains(strings.ToLower(en.Summary), "boss defeated") {
bossKilled = true
}
}
if bossKilled {
if l := flavor.Pick(flavor.EveningRecapBossKilled); l != "" {
return l
}
}
// "Nothing happened" = no combat, no narrative-action entries today.
if combatCount == 0 && len(today) <= 1 {
if l := flavor.Pick(flavor.EveningRecapNothingHappened); l != "" {
return l
}
}
return flavor.Pick(flavor.EveningRecapGeneric)
}
// dayLogEntries returns log entries for a specific expedition-day, oldest first.
func dayLogEntries(expID string, day int) ([]ExpeditionEntry, error) {
rows, err := db.Get().Query(`
SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
FROM dnd_expedition_log
WHERE expedition_id = ? AND day = ?
ORDER BY timestamp ASC, entry_id ASC`, expID, day)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExpeditionEntry
for rows.Next() {
var e ExpeditionEntry
if err := rows.Scan(
&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
&e.Type, &e.Summary, &e.Flavor,
); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// renderMorningBriefing builds the §12.1 block.
func renderMorningBriefing(e *Expedition, line string, burnConsumed float32) string {
zone, _ := getZone(e.ZoneID)
burn := currentBurn(e)
var b strings.Builder
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Morning Briefing, Day %d**\n\n", e.CurrentDay))
b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burned %.1f overnight, ~%d days left)_\n",
e.Supplies.Current, e.Supplies.Max, burnConsumed, estimateDays(e.Supplies.Current, burn)))
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
if e.TemporalStack != 0 {
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", e.TemporalStack))
}
if state := supplyDepletion(e.Supplies); state != SupplyNormal {
b.WriteString(fmt.Sprintf("⚠ **%s** (roll %d)\n",
depletionLabel(state), supplyRollModifier(state)))
}
if line != "" {
b.WriteString("\n")
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}
// renderEveningRecap builds the §12.2 block.
func renderEveningRecap(e *Expedition, line string, today []ExpeditionEntry) string {
zone, _ := getZone(e.ZoneID)
combatCount := 0
for _, en := range today {
if en.Type == "combat" {
combatCount++
}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("🎭 **TwinBee — Evening Recap, Day %d**\n\n", e.CurrentDay))
b.WriteString(fmt.Sprintf("📍 **Zone:** %s\n", zone.Display))
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU\n", e.Supplies.Current, e.Supplies.Max))
b.WriteString(fmt.Sprintf("⚔ **Combat encounters today:** %d\n", combatCount))
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
e.ThreatLevel, threatThresholdLabel(e.ThreatLevel, e.SiegeMode)))
if e.Camp == nil || !e.Camp.Active {
b.WriteString("\n_You haven't camped tonight. The dungeon does not._\n")
}
if line != "" {
b.WriteString("\n")
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}