Adv 2.0 D&D Phase R7: post-completion audit hardening

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 19:29:54 -07:00
parent 8a1d9a16ce
commit 45863bd5f3
19 changed files with 837 additions and 273 deletions

View File

@@ -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 {

View File

@@ -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 60180 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 60180 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)
}
}
}

View File

@@ -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
})
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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) // 821 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) // 821 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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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;

View File

@@ -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

View File

@@ -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 <id>`.")
}
_ = 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 <id>`.")
}
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 <id>`.")
}
_ = 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 <id>`.")
}
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))

View File

@@ -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

View File

@@ -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.