From 45863bd5f3de5d880e95fe05f355febb19ea67ed Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 19:29:54 -0700 Subject: [PATCH] Adv 2.0 D&D Phase R7: post-completion audit hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-priority fixes from the multi-agent audit of Adventure 2.0: - Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a placeholder instead of panicking. - Briefing/recap idempotency: deliverBriefing/deliverRecap now claim the rollover via a conditional UPDATE. Double-fires from clock skew or restart become no-ops; supply burn and day++ no longer reapply. - Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all 11 background tickers now select on stopCh in addition to ticker.C. - Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation so sub-hour gaps decay correctly. - 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and abandons stale runs whose LastActionAt is over 24h old. - Respec / auto-migrate orphan cleanup: !respec and the auto-migrated draft wipe now abandon active zone runs and expeditions before deleting the dnd_character row. - Phase R combat-link: applyBossDefeatThreat now wired from resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds +5/+8 from non-boss/elite kills (§8.1). - Starvation → forced extraction: briefing-time check forces extract with §10.2 coin tax when supplies hit zero. - GMNat20/Nat1 narration wired into resolveCombatRoom and resolveBossRoom (nat-20 takes precedence over nat-1). - Treasure-undo race: LoadAndDelete on both timer-fire and `undo` paths so only one side wins. - Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6 of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike) documented inline as needing ally/reaction mechanics the engine doesn't model. - Threat-70 warning: tracked in RegionState["siege_warning_fired"] so a drop-and-recross doesn't re-fire the beat. - region_state JSON decode error now logged via slog.Warn instead of silently discarded. Failing TestProdDB_DnDLayer fixed via option (a): track migrated characters and only run round-trip / idempotency assertions on those, skipping pre-existing prod-DB rows accumulated from live bot use. New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon, briefing double-fire idempotency, threat-70 warning idempotency, multi-region extract→resume state preservation, and starvation forced-extraction. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 41 ++- internal/plugin/adventure_events.go | 101 +++---- internal/plugin/adventure_hospital.go | 61 +++-- internal/plugin/adventure_mortgage.go | 61 +++-- internal/plugin/adventure_rival.go | 65 +++-- internal/plugin/adventure_robbie.go | 47 ++-- internal/plugin/adventure_scheduler.go | 101 ++++--- internal/plugin/coop_dungeon_scheduler.go | 43 +-- internal/plugin/dnd_audit_phase_R7_test.go | 256 ++++++++++++++++++ internal/plugin/dnd_expedition.go | 8 +- internal/plugin/dnd_expedition_cycle.go | 98 +++++-- internal/plugin/dnd_expedition_threat.go | 30 +- .../plugin/dnd_proddb_integration_test.go | 30 +- internal/plugin/dnd_setup.go | 17 ++ internal/plugin/dnd_subclass_combat.go | 42 +++ internal/plugin/dnd_zone.go | 24 +- internal/plugin/dnd_zone_cmd.go | 51 +++- internal/plugin/dnd_zone_narration.go | 15 +- internal/plugin/dnd_zone_run.go | 19 +- 19 files changed, 837 insertions(+), 273 deletions(-) create mode 100644 internal/plugin/dnd_audit_phase_R7_test.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index a68c410..038c272 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -39,6 +39,29 @@ type AdventurePlugin struct { treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal) morningHour int summaryHour int + + // stopCh is closed by Stop() to signal all background tickers + // (morning/summary/midnight/event/rival/robbie/hospital/mortgage/ + // coop/expedition briefing+recap) to exit. Each ticker selects + // on its time.Ticker channel and stopCh; the second branch returns. + stopCh chan struct{} +} + +// Stop signals all background tickers to exit and waits for them via +// the closed channel. Idempotent — closing a closed channel would +// panic, so we guard with a sync.Once-style nil check. +func (p *AdventurePlugin) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + if p.stopCh == nil { + return + } + select { + case <-p.stopCh: + // already closed + default: + close(p.stopCh) + } } // advUserLock returns a per-user mutex to prevent concurrent action resolution. @@ -180,7 +203,10 @@ func (p *AdventurePlugin) Init() error { // Revive any characters whose DeadUntil has expired p.catchUpRespawns(chars) - // Start schedulers + // Start schedulers — single shared stopCh allows graceful shutdown. + if p.stopCh == nil { + p.stopCh = make(chan struct{}) + } go p.morningTicker() go p.summaryTicker() go p.midnightTicker() @@ -1092,13 +1118,11 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha announceDef := *drop.Def announceLoc := *loc announceTimer := time.AfterFunc(advTreasureUndoWindow, func() { - // Token is deleted on undo, so only fire when it's still present. - // The map entry also gets cleared lazily below by future undo - // attempts; firing once based on Timer presence is enough. - if _, ok := p.treasureUndo.Load(string(userID)); !ok { + // Atomically claim the token so a concurrent `undo` reply + // can't also fire the announcement (or vice-versa). + if _, ok := p.treasureUndo.LoadAndDelete(string(userID)); !ok { return } - p.treasureUndo.Delete(string(userID)) p.announceTreasureToRoom(&announceChar, &announceDef, &announceLoc) }) @@ -1120,12 +1144,13 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha // was applied. Stale or missing tokens return false so the caller falls // through to the regular DM dispatch. func (p *AdventurePlugin) tryTreasureUndo(userID id.UserID) bool { - val, ok := p.treasureUndo.Load(string(userID)) + // LoadAndDelete races against the announce timer's claim — only + // one side wins, the other no-ops. + val, ok := p.treasureUndo.LoadAndDelete(string(userID)) if !ok { return false } tok := val.(*advTreasureUndoToken) - p.treasureUndo.Delete(string(userID)) // Cancel the deferred room announcement — a reversed swap shouldn't // produce a public "X recovered Y" line. if tok.AnnounceTimer != nil { diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 2812dfe..b9f1dd1 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -41,61 +41,66 @@ func (p *AdventurePlugin) eventTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - dateKey := now.Format("2006-01-02") - currentMinute := now.Hour()*60 + now.Minute() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + dateKey := now.Format("2006-01-02") + currentMinute := now.Hour()*60 + now.Minute() - // Expire stale pending events every tick - expireAdvPendingEvents() + // Expire stale pending events every tick + expireAdvPendingEvents() - advEventScheduleMu.Lock() - if advEventScheduleDay != dateKey { - advEventSchedule = make(map[string]int) - advEventRolled = make(map[string]bool) - advEventScheduleDay = dateKey - } + advEventScheduleMu.Lock() + if advEventScheduleDay != dateKey { + advEventSchedule = make(map[string]int) + advEventRolled = make(map[string]bool) + advEventScheduleDay = dateKey + } - // Schedule deferred rolls for any newly-acted players - chars, err := loadAllAdvCharacters() - if err != nil { - slog.Error("adventure: events: failed to load chars", "err", err) + // Schedule deferred rolls for any newly-acted players + chars, err := loadAllAdvCharacters() + if err != nil { + slog.Error("adventure: events: failed to load chars", "err", err) + advEventScheduleMu.Unlock() + continue + } + for _, c := range chars { + uid := string(c.UserID) + if !c.Alive || advEventRolled[uid] { + continue + } + if _, scheduled := advEventSchedule[uid]; scheduled { + continue + } + if !c.HasActedToday() { + continue + } + // Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC. + rollMinute := currentMinute + 60 + rand.IntN(121) + if rollMinute > 23*60+50 { + rollMinute = 23*60 + 50 + } + advEventSchedule[uid] = rollMinute + slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute) + } + + // Find players whose roll-minute has arrived + var toRoll []id.UserID + for uid, minute := range advEventSchedule { + if minute <= currentMinute && !advEventRolled[uid] { + toRoll = append(toRoll, id.UserID(uid)) + advEventRolled[uid] = true + } + } advEventScheduleMu.Unlock() - continue - } - for _, c := range chars { - uid := string(c.UserID) - if !c.Alive || advEventRolled[uid] { - continue - } - if _, scheduled := advEventSchedule[uid]; scheduled { - continue - } - if !c.HasActedToday() { - continue - } - // Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC. - rollMinute := currentMinute + 60 + rand.IntN(121) - if rollMinute > 23*60+50 { - rollMinute = 23*60 + 50 - } - advEventSchedule[uid] = rollMinute - slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute) - } - // Find players whose roll-minute has arrived - var toRoll []id.UserID - for uid, minute := range advEventSchedule { - if minute <= currentMinute && !advEventRolled[uid] { - toRoll = append(toRoll, id.UserID(uid)) - advEventRolled[uid] = true + for _, uid := range toRoll { + p.tryTriggerEvent(uid) } } - advEventScheduleMu.Unlock() - - for _, uid := range toRoll { - p.tryTriggerEvent(uid) - } } } diff --git a/internal/plugin/adventure_hospital.go b/internal/plugin/adventure_hospital.go index cdb090b..b259157 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -248,36 +248,41 @@ func (p *AdventurePlugin) hospitalNudgeTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - p.hospitalNudges.Range(func(key, val any) bool { - uid := key.(string) - nudgeAt := val.(time.Time) - if now.Before(nudgeAt) { - return true - } - - // Due — remove regardless of outcome - p.hospitalNudges.Delete(uid) - - userID := id.UserID(uid) - char, err := loadAdvCharacter(userID) - if err != nil || char.Alive { - return true - } - - // Don't nudge if already in hospital flow - if v, ok := p.pending.Load(uid); ok { - if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" { + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + p.hospitalNudges.Range(func(key, val any) bool { + uid := key.(string) + nudgeAt := val.(time.Time) + if now.Before(nudgeAt) { return true } - } - text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge") - if err := p.SendDM(userID, text); err != nil { - slog.Error("hospital: failed to send nudge", "user", userID, "err", err) - } - return true - }) + // Due — remove regardless of outcome + p.hospitalNudges.Delete(uid) + + userID := id.UserID(uid) + char, err := loadAdvCharacter(userID) + if err != nil || char.Alive { + return true + } + + // Don't nudge if already in hospital flow + if v, ok := p.pending.Load(uid); ok { + if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" { + return true + } + } + + text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge") + if err := p.SendDM(userID, text); err != nil { + slog.Error("hospital: failed to send nudge", "user", userID, "err", err) + } + return true + }) + } } } diff --git a/internal/plugin/adventure_mortgage.go b/internal/plugin/adventure_mortgage.go index 9f89cb3..f295de9 100644 --- a/internal/plugin/adventure_mortgage.go +++ b/internal/plugin/adventure_mortgage.go @@ -107,35 +107,40 @@ func (p *AdventurePlugin) mortgageTicker() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - // Run on Mondays only (FRED updates Thursdays, we process Mondays) - if now.Weekday() != time.Monday { - continue + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + // Run on Mondays only (FRED updates Thursdays, we process Mondays) + if now.Weekday() != time.Monday { + continue + } + + _, week := now.ISOWeek() + dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week) + jobName := "mortgage_weekly" + if db.JobCompleted(jobName, dateKey) { + continue + } + + slog.Info("mortgage: running weekly payment processing") + + // Fetch fresh rate and compare with previous + newRate := getCurrentMortgageRate() + oldRate := p.getLastKnownRate() + + if oldRate > 0 && oldRate != newRate { + p.sendMortgageRateChangeDMs(oldRate, newRate) + } + p.setLastKnownRate(newRate) + + // Process payments + p.processMortgagePayments() + + db.MarkJobCompleted(jobName, dateKey) } - - _, week := now.ISOWeek() - dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week) - jobName := "mortgage_weekly" - if db.JobCompleted(jobName, dateKey) { - continue - } - - slog.Info("mortgage: running weekly payment processing") - - // Fetch fresh rate and compare with previous - newRate := getCurrentMortgageRate() - oldRate := p.getLastKnownRate() - - if oldRate > 0 && oldRate != newRate { - p.sendMortgageRateChangeDMs(oldRate, newRate) - } - p.setLastKnownRate(newRate) - - // Process payments - p.processMortgagePayments() - - db.MarkJobCompleted(jobName, dateKey) } } diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 9862210..c36d1a3 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -778,40 +778,45 @@ func (p *AdventurePlugin) rivalChallengeTicker() { // Roll the next challenge interval once. Re-roll after each issued challenge. nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1) - for range ticker.C { - now := time.Now().UTC() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() - // Only fire on the hour. - if now.Minute() != 0 { - continue - } + // Only fire on the hour. + if now.Minute() != 0 { + continue + } - // Only issue challenges between 08:00 and 22:00 UTC. - if now.Hour() < 8 || now.Hour() >= 22 { - // Still check for expiry outside challenge hours. + // Only issue challenges between 08:00 and 22:00 UTC. + if now.Hour() < 8 || now.Hour() >= 22 { + // Still check for expiry outside challenge hours. + p.expireRivalChallenges() + continue + } + + // Expire old challenges first. p.expireRivalChallenges() - continue + + // Check if enough time has passed since last challenge. + last := lastRivalChallengeTime() + if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour { + continue + } + + // Try to issue a challenge. + challenger, challenged := p.selectRivalPair() + if challenger == nil || challenged == nil { + continue + } + + p.issueRivalChallenge(challenger, challenged) + + // Roll a fresh interval for the next challenge. + nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1) } - - // Expire old challenges first. - p.expireRivalChallenges() - - // Check if enough time has passed since last challenge. - last := lastRivalChallengeTime() - if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour { - continue - } - - // Try to issue a challenge. - challenger, challenged := p.selectRivalPair() - if challenger == nil || challenged == nil { - continue - } - - p.issueRivalChallenge(challenger, challenged) - - // Roll a fresh interval for the next challenge. - nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1) } } diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index 3221766..288a700 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -31,29 +31,34 @@ func (p *AdventurePlugin) robbieTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - dateKey := now.Format("2006-01-02") + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + dateKey := now.Format("2006-01-02") - // At midnight (or first tick of the day), pick today's target hour. - if robbieTargetDay != dateKey { - robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive - robbieTargetDay = dateKey - slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey) + // At midnight (or first tick of the day), pick today's target hour. + if robbieTargetDay != dateKey { + robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive + robbieTargetDay = dateKey + slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey) + } + + if now.Hour() < robbieTargetHour { + continue + } + + jobName := "adventure_robbie" + if db.JobCompleted(jobName, dateKey) { + continue + } + + slog.Info("adventure: robbie sweep starting") + p.robbieVisitAll() + db.MarkJobCompleted(jobName, dateKey) } - - if now.Hour() < robbieTargetHour { - continue - } - - jobName := "adventure_robbie" - if db.JobCompleted(jobName, dateKey) { - continue - } - - slog.Info("adventure: robbie sweep starting") - p.robbieVisitAll() - db.MarkJobCompleted(jobName, dateKey) } } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 3f2d446..7f71265 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -18,21 +18,26 @@ func (p *AdventurePlugin) morningTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - if now.Hour() != p.morningHour || now.Minute() != 0 { - continue - } + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + if now.Hour() != p.morningHour || now.Minute() != 0 { + continue + } - dateKey := now.Format("2006-01-02") - jobName := "adventure_morning" - if db.JobCompleted(jobName, dateKey) { - continue - } + dateKey := now.Format("2006-01-02") + jobName := "adventure_morning" + if db.JobCompleted(jobName, dateKey) { + continue + } - slog.Info("adventure: sending morning DMs") - p.sendMorningDMs() - db.MarkJobCompleted(jobName, dateKey) + slog.Info("adventure: sending morning DMs") + p.sendMorningDMs() + db.MarkJobCompleted(jobName, dateKey) + } } } @@ -154,21 +159,26 @@ func (p *AdventurePlugin) summaryTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - if now.Hour() != p.summaryHour || now.Minute() != 0 { - continue - } + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + if now.Hour() != p.summaryHour || now.Minute() != 0 { + continue + } - dateKey := now.Format("2006-01-02") - jobName := "adventure_summary" - if db.JobCompleted(jobName, dateKey) { - continue - } + dateKey := now.Format("2006-01-02") + jobName := "adventure_summary" + if db.JobCompleted(jobName, dateKey) { + continue + } - slog.Info("adventure: posting daily summary") - p.postDailySummary() - db.MarkJobCompleted(jobName, dateKey) + slog.Info("adventure: posting daily summary") + p.postDailySummary() + db.MarkJobCompleted(jobName, dateKey) + } } } @@ -303,26 +313,31 @@ func (p *AdventurePlugin) midnightTicker() { lastRanDate := "" - for range ticker.C { - dateKey := time.Now().UTC().Format("2006-01-02") - if dateKey == lastRanDate { - continue - } + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + dateKey := time.Now().UTC().Format("2006-01-02") + if dateKey == lastRanDate { + continue + } - // New UTC day — check DB in case we already ran (e.g. bot restart). - jobName := "adventure_midnight" - if db.JobCompleted(jobName, dateKey) { + // New UTC day — check DB in case we already ran (e.g. bot restart). + jobName := "adventure_midnight" + if db.JobCompleted(jobName, dateKey) { + lastRanDate = dateKey + continue + } + + slog.Info("adventure: midnight reset") + if err := p.midnightReset(); err != nil { + slog.Error("adventure: midnight reset failed, will retry next tick", "err", err) + continue + } + db.MarkJobCompleted(jobName, dateKey) lastRanDate = dateKey - continue } - - slog.Info("adventure: midnight reset") - if err := p.midnightReset(); err != nil { - slog.Error("adventure: midnight reset failed, will retry next tick", "err", err) - continue - } - db.MarkJobCompleted(jobName, dateKey) - lastRanDate = dateKey } } diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go index 44f8b52..08b47f8 100644 --- a/internal/plugin/coop_dungeon_scheduler.go +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -26,28 +26,33 @@ func (p *AdventurePlugin) coopTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - // Locks: every minute. - p.coopProcessLocks() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + // Locks: every minute. + p.coopProcessLocks() - // Gift expiries: every minute. Resolves any gift whose individual - // 6h voting window has elapsed. Modifier sits on the run until the - // next floor resolution merges it. - p.coopProcessGiftExpiries() + // Gift expiries: every minute. Resolves any gift whose individual + // 6h voting window has elapsed. Modifier sits on the run until the + // next floor resolution merges it. + p.coopProcessGiftExpiries() - // Resolutions: once per day at morningHour:00 UTC. - now := time.Now().UTC() - if now.Hour() != p.morningHour || now.Minute() != 0 { - continue + // Resolutions: once per day at morningHour:00 UTC. + now := time.Now().UTC() + if now.Hour() != p.morningHour || now.Minute() != 0 { + continue + } + dateKey := now.Format("2006-01-02") + jobName := "coop_dungeon_daily" + if db.JobCompleted(jobName, dateKey) { + continue + } + slog.Info("coop: daily tick — resolving active runs") + p.coopProcessActiveRuns() + db.MarkJobCompleted(jobName, dateKey) } - dateKey := now.Format("2006-01-02") - jobName := "coop_dungeon_daily" - if db.JobCompleted(jobName, dateKey) { - continue - } - slog.Info("coop: daily tick — resolving active runs") - p.coopProcessActiveRuns() - db.MarkJobCompleted(jobName, dateKey) } } diff --git a/internal/plugin/dnd_audit_phase_R7_test.go b/internal/plugin/dnd_audit_phase_R7_test.go new file mode 100644 index 0000000..d26c78e --- /dev/null +++ b/internal/plugin/dnd_audit_phase_R7_test.go @@ -0,0 +1,256 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Phase-R7 hardening tests covering the audit gaps: 24h auto-abandon, +// briefing idempotency, threat-70 warning idempotency, multi-region +// extract→resume, and starvation→forced extraction. + +// ── 24-hour zone-run auto-abandon (§4.3) ──────────────────────────────────── + +func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@stale-run:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + // Backdate last_action_at to 25 hours ago. + if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil { + t.Fatalf("backdate: %v", err) + } + got, err := getActiveZoneRun(uid) + if err != nil { + t.Fatalf("getActiveZoneRun: %v", err) + } + if got != nil { + t.Errorf("expected nil after 24h timeout, got run %q", got.RunID) + } + // And the row is marked abandoned. + raw, _ := getZoneRun(run.RunID) + if raw == nil || !raw.Abandoned { + t.Errorf("run not abandoned: %+v", raw) + } +} + +func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@fresh-run:example") + defer cleanupExpeditions(uid) + + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil) + if err != nil { + t.Fatal(err) + } + got, err := getActiveZoneRun(uid) + if err != nil || got == nil { + t.Fatalf("fresh run dropped: got=%v err=%v", got, err) + } + if got.RunID != run.RunID { + t.Errorf("returned wrong run: %q vs %q", got.RunID, run.RunID) + } +} + +// ── Briefing idempotency: double-fire same day is a no-op ─────────────────── + +func TestDeliverBriefing_DoubleFireIsIdempotent(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@brief-idem:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + // Backdate start so the briefing precondition (start_date < threshold) passes. + if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { + t.Fatalf("backdate: %v", err) + } + + p := &AdventurePlugin{} + now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) + + if err := p.deliverBriefing(exp, now); err != nil { + t.Fatalf("first deliver: %v", err) + } + first, _ := getExpedition(exp.ID) + day1 := first.CurrentDay + supplies1 := first.Supplies.Current + + // Re-fetch as a fresh copy (simulates a second tick reading the row + // before its claimed last_briefing_at lands in the loader filter). + exp2, _ := getExpedition(exp.ID) + exp2.Supplies = first.Supplies // pretend stale snapshot + if err := p.deliverBriefing(exp2, now); err != nil { + t.Fatalf("second deliver: %v", err) + } + second, _ := getExpedition(exp.ID) + if second.CurrentDay != day1 { + t.Errorf("day double-bumped: %d → %d", day1, second.CurrentDay) + } + if second.Supplies.Current != supplies1 { + t.Errorf("supplies double-burned: %.1f → %.1f", supplies1, second.Supplies.Current) + } +} + +// ── Threat-70 warning fires once across multiple crossings ────────────────── + +func TestApplyDailyThreatDrift_70WarningOnceOnly(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@threat-70-once:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if err := applyThreatDelta(exp.ID, 65, "seed"); err != nil { + t.Fatal(err) + } + + // Cross 70 the first time. + // Push over 70 the first time. + if err := applyThreatDelta(exp.ID, 6, "drift"); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + if _, _, err := applyDailyThreatDrift(exp); err != nil { + t.Fatalf("first drift: %v", err) + } + + // Drop below 70 then bump back over. + if err := applyThreatDelta(exp.ID, -15, "fortified rest"); err != nil { + t.Fatal(err) + } + if err := applyThreatDelta(exp.ID, 10, "loud combat"); err != nil { + t.Fatal(err) + } + exp, _ = getExpedition(exp.ID) + if _, _, err := applyDailyThreatDrift(exp); err != nil { + t.Fatalf("second drift: %v", err) + } + + rows, err := recentExpeditionLog(exp.ID, 50) + if err != nil { + t.Fatal(err) + } + warnings := 0 + for _, r := range rows { + if r.Type == "threat" && strings.Contains(strings.ToLower(r.Summary), "siege approaches") { + warnings++ + } + } + if warnings > 1 { + t.Errorf("threat-70 warning fired %d times across drift loop; want at most 1", warnings) + } +} + +// ── Starvation triggers forced extraction at briefing time ────────────────── + +func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@starve-extract:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 0.5, Max: 10, DailyBurn: 5, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { + t.Fatalf("backdate: %v", err) + } + + p := &AdventurePlugin{} + now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) + if err := p.deliverBriefing(exp, now); err != nil { + t.Fatalf("deliver: %v", err) + } + + got, _ := getExpedition(exp.ID) + if got.Status != ExpeditionStatusAbandoned { + t.Errorf("status = %q after starvation briefing, want abandoned", got.Status) + } +} + +// ── Multi-region extract→resume preserves region state ────────────────────── + +func TestExtractResume_MultiRegionPreservesState(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@multiregion-resume:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneUnderdark, "underdark_surface_tunnels", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + // Visit a second region and mark a region-boss kill. + if _, err := MarkRegionVisited(exp, "underdark_drow_outpost"); err != nil { + t.Fatalf("mark visited: %v", err) + } + if _, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil { + t.Fatalf("mark boss: %v", err) + } + + // Extract. + if _, err := voluntaryExtractExpedition(uid); err != nil { + t.Fatalf("extract: %v", err) + } + + // Resume with fresh supplies. + resumed, err := getResumableExpedition(uid) + if err != nil || resumed == nil { + t.Fatalf("getResumable: %v %v", resumed, err) + } + if err := resumeExpedition(resumed.ID, ExpeditionSupplies{ + Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, + }); err != nil { + t.Fatalf("resume: %v", err) + } + + // Re-load — region state must survive. + live, _ := getActiveExpedition(uid) + if live == nil { + t.Fatal("post-resume: no active expedition") + } + if !IsRegionVisited(live, "underdark_drow_outpost") { + t.Error("visited region lost on resume") + } + if !IsRegionCleared(live, "underdark_drow_outpost") { + t.Error("region-cleared flag lost on resume") + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func dbExecZoneRunBackdate(runID string, ago time.Duration) (int64, error) { + when := time.Now().UTC().Add(-ago) + r, err := db.Get().Exec( + `UPDATE dnd_zone_run SET last_action_at = ? WHERE run_id = ?`, when, runID) + if err != nil { + return 0, err + } + return r.RowsAffected() +} + +func dbExecExpeditionBackdate(expID string, ago time.Duration) (int64, error) { + when := time.Now().UTC().Add(-ago) + r, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, when, expID) + if err != nil { + return 0, err + } + return r.RowsAffected() +} diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index e03e253..0daccf0 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "time" "gogobee/internal/db" @@ -264,7 +265,10 @@ func scanExpedition(row scanner) (*Expedition, error) { e.ThreatEvents = []ThreatEvent{} } if regionJSON != "" { - _ = json.Unmarshal([]byte(regionJSON), &e.RegionState) + if err := json.Unmarshal([]byte(regionJSON), &e.RegionState); err != nil { + slog.Warn("expedition: region_state decode failed; falling back to empty", + "expedition", e.ID, "err", err) + } } if e.RegionState == nil { e.RegionState = map[string]any{} @@ -369,6 +373,8 @@ func applyThreatDelta(expID string, delta int, reason string) error { if level > 100 { level = 100 } + // Spec §8.3: Siege Mode is one-way — the OR keeps siege sticky + // even if a subsequent negative delta drops the underlying level. siege := e.SiegeMode || level >= 100 events := append(e.ThreatEvents, ThreatEvent{ Timestamp: time.Now().UTC(), diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 5380dc4..8d149ad 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -38,12 +38,17 @@ const ( func (p *AdventurePlugin) expeditionBriefingTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - if now.Hour() != expeditionBriefingHour || now.Minute() != 0 { - continue + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + if now.Hour() != expeditionBriefingHour || now.Minute() != 0 { + continue + } + p.fireExpeditionBriefings(now) } - p.fireExpeditionBriefings(now) } } @@ -51,12 +56,17 @@ func (p *AdventurePlugin) expeditionBriefingTicker() { func (p *AdventurePlugin) expeditionRecapTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - now := time.Now().UTC() - if now.Hour() != expeditionRecapHour || now.Minute() != 0 { - continue + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + now := time.Now().UTC() + if now.Hour() != expeditionRecapHour || now.Minute() != 0 { + continue + } + p.fireExpeditionRecaps(now) } - p.fireExpeditionRecaps(now) } } @@ -149,7 +159,29 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) { // 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.). @@ -202,7 +234,19 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { // has advanced, so e.CurrentDay reflects the new day). temporalLines := applyZoneTemporalPostRollover(e) - // E5b: if a temporal event forced extraction (abyss collapse, etc.), + // §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 { @@ -240,17 +284,30 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil { return err } - _, err := db.Get().Exec(` - UPDATE dnd_expedition - SET last_briefing_at = ?, - last_activity = ? - WHERE expedition_id = ?`, now, now, e.ID) - 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 @@ -300,12 +357,7 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error { fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil { return err } - _, err = db.Get().Exec(` - UPDATE dnd_expedition - SET last_recap_at = ?, - last_activity = ? - WHERE expedition_id = ?`, now, now, e.ID) - return err + return nil } // pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7, diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go index 351f844..8b70693 100644 --- a/internal/plugin/dnd_expedition_threat.go +++ b/internal/plugin/dnd_expedition_threat.go @@ -5,6 +5,7 @@ import ( "strings" "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" ) // Phase 12 E2a — Threat Clock state machine. @@ -134,9 +135,19 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) { if newBand != prevBand && newBand > prevBand { _ = appendThreatTransitionLog(e, newBand) } - // E2c: §8.3 — one-time warning when crossing 70. + // E2c: §8.3 — one-time-per-expedition warning when crossing 70. + // Track in RegionState so a drop-and-recross (e.g. fortified-camp + // -5 followed by daily +3 drift) doesn't re-fire the beat. if prevLevel < 70 && e.ThreatLevel >= 70 { - _ = appendApproachingSiegeLog(e) + warned, _ := e.RegionState["siege_warning_fired"].(bool) + if !warned { + _ = appendApproachingSiegeLog(e) + if e.RegionState == nil { + e.RegionState = map[string]any{} + } + e.RegionState["siege_warning_fired"] = true + _ = persistRegionState(e) + } } return delta, reason, nil } @@ -246,3 +257,18 @@ func threatBandEffectsBlock(info ThreatBandInfo) string { func applyBossDefeatThreat(expID string) error { return applyThreatDelta(expID, -20, "boss defeated") } + +// applyRoomCombatThreat records the +5 threat bump per §8.1 when the +// player resolves combat in a new (non-boss) room. Silent no-op for +// standalone zone runs (no active expedition). +func applyRoomCombatThreatForUser(userID id.UserID, elite bool) { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil { + return + } + delta := 5 + if elite { + delta = 8 // elite encounters are louder + } + _ = applyThreatDelta(exp.ID, delta, "combat in new room") +} diff --git a/internal/plugin/dnd_proddb_integration_test.go b/internal/plugin/dnd_proddb_integration_test.go index e28ff4a..7a6e70b 100644 --- a/internal/plugin/dnd_proddb_integration_test.go +++ b/internal/plugin/dnd_proddb_integration_test.go @@ -46,7 +46,9 @@ func TestProdDB_DnDLayer(t *testing.T) { t.Logf("loaded %d real adventure characters", len(chars)) // 2. For each, verify they have NO dnd_character row yet, then auto-migrate - // and verify the resulting row is sensible. + // and verify the resulting row is sensible. Track which chars we + // actually migrated so steps 3+ can target one of those. + migrated := make(map[id.UserID]bool) for i := range chars { char := &chars[i] uid := char.UserID @@ -57,7 +59,10 @@ func TestProdDB_DnDLayer(t *testing.T) { continue } if existing != nil { - t.Errorf("user=%s: had dnd_character row pre-migrate (should be empty)", uid) + // Real prod DB has accumulated rows from live bot use. + // Skip these — the auto-migrate path is exercised by + // other characters in the loop. + t.Logf("user=%s: skipping (already has dnd_character row)", uid) continue } @@ -110,14 +115,22 @@ func TestProdDB_DnDLayer(t *testing.T) { } } + migrated[uid] = true t.Logf("auto-migrated user=%s → L%d %s %s HP=%d AC=%d STR/DEX/CON/INT/WIS/CHA=%d/%d/%d/%d/%d/%d", uid, dnd.Level, dnd.Race, dnd.Class, dnd.HPMax, dnd.ArmorClass, dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA) } + if len(migrated) == 0 { + t.Skip("no characters available for auto-migrate (all pre-existing); steps 3+ require a fresh migrate") + } + // 3. Re-load each auto-migrated char — round-trip integrity. for i := range chars { uid := chars[i].UserID + if !migrated[uid] { + continue + } dnd, err := LoadDnDCharacter(uid) if err != nil { t.Errorf("user=%s: re-load failed: %v", uid, err) @@ -134,10 +147,17 @@ func TestProdDB_DnDLayer(t *testing.T) { } // 4. ensureDnDCharacterForCombat is idempotent — second call returns the - // same row, doesn't create a duplicate. - uid := chars[0].UserID + // same row, doesn't create a duplicate. Pick the first migrated char. + var idemIdx int + for i := range chars { + if migrated[chars[i].UserID] { + idemIdx = i + break + } + } + uid := chars[idemIdx].UserID first, _ := LoadDnDCharacter(uid) - second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[0]) + second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[idemIdx]) if err != nil { t.Errorf("idempotent ensure failed: %v", err) } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index d9be989..bbe1687 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -406,6 +406,15 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error { if err := wipeSpellsForUser(ctx.Sender); err != nil { slog.Error("dnd: respec spell wipe", "user", ctx.Sender, "err", err) } + // Phase R hardening: a respec also abandons any active zone-run / + // expedition keyed to the wiped character so they don't orphan with + // pointers to a stat sheet that no longer exists. + if err := abandonZoneRun(ctx.Sender); err != nil && err != ErrNoActiveRun { + slog.Warn("dnd: respec zone-run cleanup", "user", ctx.Sender, "err", err) + } + if err := abandonExpedition(ctx.Sender); err != nil && err != ErrNoActiveExpedition { + slog.Warn("dnd: respec expedition cleanup", "user", ctx.Sender, "err", err) + } // Debit last. If this fails (rare race — euros spent elsewhere between // the pre-check and now), the player got a free respec. Strictly better // than the alternative of euros-lost-with-wipe-not-applied. @@ -453,6 +462,14 @@ func loadOrInitDraft(userID id.UserID) (*DnDCharacter, error) { ); err != nil { return nil, fmt.Errorf("clear auto-migrated row: %w", err) } + // Auto-migrated wipe: also abandon active zone-run / + // expedition so they don't orphan against the deleted row. + if err := abandonZoneRun(userID); err != nil && err != ErrNoActiveRun { + slog.Warn("dnd: auto-migrated wipe zone-run cleanup", "user", userID, "err", err) + } + if err := abandonExpedition(userID); err != nil && err != ErrNoActiveExpedition { + slog.Warn("dnd: auto-migrated wipe expedition cleanup", "user", userID, "err", err) + } // fall through to fresh draft } else if !c.PendingSetup { return nil, fmt.Errorf("character already finalized — use !respec") diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index c172f04..7876465 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -1,5 +1,7 @@ package plugin +import "math" + // Phase 10 SUB2a — subclass combat hooks. // // applySubclassPassives layers subclass-driven flags onto CombatModifiers @@ -429,6 +431,46 @@ func init() { mods.HealItem += base + abilityModifier(c.CHA) }, } + // Three more maneuvers using existing CombatModifiers fields. The + // remaining four spec maneuvers (Pushing, Goading, Riposte, Commander's + // Strike) need ally-targeting / reaction mechanics that the one-shot + // engine doesn't model — they're omitted rather than approximated + // inaccurately. + dndActiveAbilities["disarming_attack"] = DnDAbility{ + ID: "disarming_attack", + Name: "Disarming Attack", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Knock the enemy's weapon aside: their damage is reduced for the rest of the fight (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + // 25% incoming-damage cut models a fighter who is now + // swinging an improvised weapon at -d4-ish. + mods.DamageReduct = math.Max(mods.DamageReduct, 0.25) + }, + } + dndActiveAbilities["menacing_attack"] = DnDAbility{ + ID: "menacing_attack", + Name: "Menacing Attack", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Snarl as you swing — the enemy hesitates and skips its first attack (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.SpellEnemySkipFirst = true + }, + } + dndActiveAbilities["parry"] = DnDAbility{ + ID: "parry", + Name: "Parry", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Set yourself for incoming blows — physical damage taken is sharply reduced (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.DamageReduct = math.Max(mods.DamageReduct, 0.40) + }, + } // Phase 10 SUB2c — Cleric Channel Divinity. All three Cleric subclasses // share the "channel_divinity" resource pool (2/long-rest in our model; diff --git a/internal/plugin/dnd_zone.go b/internal/plugin/dnd_zone.go index 05357b6..5170909 100644 --- a/internal/plugin/dnd_zone.go +++ b/internal/plugin/dnd_zone.go @@ -1,6 +1,9 @@ package plugin -import "sort" +import ( + "fmt" + "sort" +) // Phase 11 D1a — zone registry. Implements `gogobee_dungeon_zones.md` §5. // @@ -127,6 +130,25 @@ func getZone(id ZoneID) (ZoneDefinition, bool) { return z, ok } +// zoneOrFallback returns the registered ZoneDefinition or, if the id +// is unknown (corrupted DB row, dropped registration), a safe +// placeholder so display strings don't panic. Use this at non-fatal +// callsites that just need a Display/Hook/Atmosphere to print. +func zoneOrFallback(id ZoneID) ZoneDefinition { + if z, ok := dndZoneRegistry[id]; ok { + return z + } + return ZoneDefinition{ + ID: id, + Display: fmt.Sprintf("(unknown zone %q)", string(id)), + Hook: "", + Atmosphere: "", + Tier: 1, + LevelMin: 1, + LevelMax: 1, + } +} + // zonesForLevel returns all zones a player at dndLevel may enter. // Per design doc §2: a player cannot enter a zone more than 2 tiers // above their current level. We approximate "current tier" as diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 2b5a131..a4667c9 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -110,7 +110,7 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere)) } if active, _ := getActiveZoneRun(ctx.Sender); active != nil { - zone, _ := getZone(active.ZoneID) + zone := zoneOrFallback(active.ZoneID) b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._", zone.Display, active.CurrentRoom+1, active.TotalRooms)) } @@ -180,7 +180,7 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest switch err { case ErrRunAlreadyActive: active, _ := getActiveZoneRun(ctx.Sender) - zone, _ := getZone(active.ZoneID) + zone := zoneOrFallback(active.ZoneID) return p.SendDM(ctx.Sender, fmt.Sprintf( "You're already in **%s** (room %d/%d). Finish it or `!zone abandon` first.", zone.Display, active.CurrentRoom+1, active.TotalRooms)) @@ -193,7 +193,7 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest return p.SendDM(ctx.Sender, "Couldn't start zone run: "+err.Error()) } } - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) var b strings.Builder b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms)) b.WriteString("_" + zone.Hook + "_\n\n") @@ -221,7 +221,7 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter `.") } _ = applyMoodDecayIfStale(run) - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) var b strings.Builder b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n", zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType()))) @@ -279,7 +279,7 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) cleared := map[int]bool{} for _, r := range run.RoomsCleared { cleared[r] = true @@ -354,7 +354,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } _ = applyMoodDecayIfStale(run) - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) prev := run.CurrentRoomType() prevIdx := run.CurrentRoom @@ -479,7 +479,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z if err != nil { return "", false, err } - scanMoodEventsFromCombat(run.RunID, result) + nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result) var b strings.Builder if elite { @@ -492,6 +492,20 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z b.WriteString(line) b.WriteString("\n") } + // Surface the most-impactful crit/fumble as TwinBee narration. A run + // with both gets the nat-20 line — players already see the fumble in + // the combat log, but the gloat lands harder than the pity. + if nat20s > 0 { + if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } else if nat1s > 0 { + if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } if elite { b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC)) } else { @@ -513,6 +527,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z } b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP)) recordZoneKillForUser(userID, monster.ID) + applyRoomCombatThreatForUser(userID, elite) if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { b.WriteString("\n") b.WriteString(drop) @@ -531,10 +546,21 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon if err != nil { return "", false, err } - scanMoodEventsFromCombat(run.RunID, result) + nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result) var b strings.Builder b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC)) + if nat20s > 0 { + if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } else if nat1s > 0 { + if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + } if phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) { if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" { b.WriteString(line) @@ -557,6 +583,11 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon } b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP)) recordZoneKillForUser(userID, monster.ID) + // §8.1: zone boss defeat drops expedition threat by 20. Silent + // no-op for standalone zone runs (no active expedition). + if exp, err := getActiveExpedition(userID); err == nil && exp != nil { + _ = applyBossDefeatThreat(exp.ID) + } if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" { b.WriteString("\n") b.WriteString(drop) @@ -627,7 +658,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom) if line == "" { return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.") @@ -648,7 +679,7 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error { if run == nil { return p.SendDM(ctx.Sender, "Run abandoned.") } - zone, _ := getZone(run.ZoneID) + zone := zoneOrFallback(run.ZoneID) return p.SendDM(ctx.Sender, fmt.Sprintf( "🚪 Abandoned **%s** at room %d/%d. No rewards.", zone.Display, run.CurrentRoom+1, run.TotalRooms)) diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 8828375..b9559df 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "hash/fnv" + "math" "time" "gogobee/internal/db" @@ -465,12 +466,18 @@ func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) { // Design doc §3.2: "Mood decays toward 50 at a rate of ±2 per hour // (passive rebalancing)." Long-idle runs reset toward neutral. func passiveDecayMood(score int, lastTouched, now time.Time) int { - hours := int(now.Sub(lastTouched).Hours()) - if hours <= 0 { + elapsedHours := now.Sub(lastTouched).Hours() + if elapsedHours <= 0 { + return clampMood(score) + } + const ratePerHour = 2.0 + // Round to nearest unit so a 30-minute gap decays 1, a 12-minute + // gap decays 0. Avoids the truncation bug where any sub-hour gap + // produced no decay. + drift := int(math.Round(elapsedHours * ratePerHour)) + if drift <= 0 { return clampMood(score) } - const ratePerHour = 2 - drift := hours * ratePerHour switch { case score > 50: score -= drift diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 946c97f..9f5b0e8 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -202,7 +202,15 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) return run, nil } -// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if none. +// zoneRunInactivityTimeout is the §4.3 stale-run threshold: a run that +// has gone untouched for this long is auto-abandoned the next time +// anyone looks at it. +const zoneRunInactivityTimeout = 24 * time.Hour + +// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if +// none. If the most-recent active run has been idle longer than +// zoneRunInactivityTimeout, it's auto-abandoned (§4.3) and the +// function returns (nil, nil) — the caller sees a clean slate. func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { row := db.Get().QueryRow(` SELECT run_id, user_id, zone_id, current_room, total_rooms, @@ -220,7 +228,14 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return r, err + if err != nil { + return nil, err + } + if time.Since(r.LastActionAt) > zoneRunInactivityTimeout { + _ = abandonZoneRunByID(r.RunID) + return nil, nil + } + return r, nil } // getZoneRun fetches by RunID regardless of completion state. Test/admin use.