diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 1b51d49..c525121 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -120,6 +120,13 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { return } + // Mid-fight: a turn-based session locks the run. Don't drop a random + // overworld event into a live fight — the player can't act on it without + // finishing the fight first, and the trigger DM talks over the combat feed. + if hasActiveCombatSession(userID) { + return + } + // 0.5% chance if rand.Float64() >= 0.005 { return diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index febac2d..7988f3f 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -98,6 +98,15 @@ func (p *AdventurePlugin) sendMorningDMs() { continue } + // Mid-fight: a turn-based elite/boss session locks the run. The + // per-round combat DMs are the player's feed right now — don't talk + // over them with the overworld morning menu. (A combat session always + // sits inside an active expedition, so the expedition skip below would + // usually catch this too; the explicit guard is cheap insurance.) + if char.Alive && hasActiveCombatSession(char.UserID) { + continue + } + // Active expedition: the expedition cycle delivers its own morning // briefing at 06:00 UTC (deliverBriefing). The legacy overworld // morning DM is irrelevant — and confusing — while underground. @@ -402,14 +411,22 @@ func (p *AdventurePlugin) midnightReset() error { continue } - // Active expedition counts as activity. The expedition system tracks - // its own action flow (zone/harvest/combat/transit/extract) and never - // touches the legacy CombatActionsUsed/HarvestActionsUsed counters, so - // HasActedToday() reports false for expeditioners. Treat them like the - // acted-today branch below: advance the streak and bail out. + // An active expedition — or a turn-based fight locked open across + // midnight — counts as activity. Both track their own action flow + // (zone/harvest/combat/transit/extract) and never touch the legacy + // CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday() + // reports false. Treat them like the acted-today branch below: + // advance the streak and bail out (no idle-shame, no streak decay). + busy := false if exp, err := getActiveExpedition(char.UserID); err != nil { slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) } else if exp != nil { + busy = true + } + if !busy && hasActiveCombatSession(char.UserID) { + busy = true + } + if busy { if char.LastActionDate == yesterday || char.LastActionDate == today { char.CurrentStreak++ } else { diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 8cfde86..3f21963 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "time" "gogobee/internal/db" @@ -256,6 +257,23 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) { return s, err } +// hasActiveCombatSession reports whether the player is currently locked into a +// turn-based elite/boss fight. Daily-cron and periodic-ticker paths consult +// this to avoid firing DMs or mutating run state into a live fight: an active +// session locks the run (no `!zone advance`), so the player can't move it +// forward and a cron path shouldn't either. The session carries a 1h TTL, so +// the collision window is bounded — but real, since a fight can straddle any +// scheduled tick. On a lookup error this returns false (fail-open): a missed +// guard is a confusing DM, not corruption. +func hasActiveCombatSession(userID id.UserID) bool { + s, err := getActiveCombatSession(userID) + if err != nil { + slog.Warn("combat: cron guard failed to check active session", "user", userID, "err", err) + return false + } + return s != nil +} + // getCombatSession fetches by ID regardless of status. Test/admin/reaper use. func getCombatSession(sessionID string) (*CombatSession, error) { row := db.Get().QueryRow(`SELECT `+combatSessionCols+` diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go index 94c02a7..33a96e4 100644 --- a/internal/plugin/combat_session_test.go +++ b/internal/plugin/combat_session_test.go @@ -138,6 +138,34 @@ func TestListExpiredCombatSessions(t *testing.T) { } } +func TestHasActiveCombatSession(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-guard:example.org") + defer cleanupCombatSessions(uid) + + if hasActiveCombatSession(uid) { + t.Fatal("no session yet, want false") + } + + s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 120, 120) + if err != nil { + t.Fatal(err) + } + if !hasActiveCombatSession(uid) { + t.Error("active session, want true") + } + + // A terminal session no longer counts as active. + s.Status = CombatStatusWon + s.Phase = CombatPhaseOver + if err := saveCombatSession(s); err != nil { + t.Fatal(err) + } + if hasActiveCombatSession(uid) { + t.Error("session is won, want false") + } +} + // ── State machine ────────────────────────────────────────────────────────── // turnSession builds an in-memory CombatSession for state-machine tests that diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 8d149ad..b5ce4c2 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -81,6 +81,16 @@ func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) { 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) } @@ -98,6 +108,13 @@ func (p *AdventurePlugin) fireExpeditionRecaps(now time.Time) { 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) }