From 0f72484653a9be41009bb5e3e468461c0a839e4a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 23 May 2026 10:30:47 -0700 Subject: [PATCH] Idle reaper: use unified activity oracle, hold streak on autopilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit midnightReset gated shame DMs on LastActionDate + a fallback busy check for active expedition / combat session. Players running on background autopilot got shamed at midnight: dnd_zone_cmd.go gates markActedToday on !compact (to keep autopilot out of streak credit), so LastActionDate stayed stale; and if the autopilot's expedition completed / extracted / got force-extracted before midnight, the busy check also missed it (getActiveExpedition only matches status='active'). Switch the reaper to loadAdvDailyActivity — the same oracle the daily report uses, which unions adventure_activity_log + dnd_zone_run + dnd_expedition_log. Three explicit branches: - engaged (LastActionDate today/yesterday or legacy counters bumped) → streak bumps, LastActionDate restamped - activity-without-tap (autopilot beats, expedition log, or live active expedition/combat as safety net) → hold: no bump, no shame, no decay - truly idle → shame DM + streak halve The old busy branch bumped streak; new behavior holds. Aligns autopilot days with mid-fight-at-midnight days: both engaged earlier but typed nothing today, neither pumps the streak. Tests cover all three branches + the death-window early-return. --- internal/plugin/adventure_scheduler.go | 143 ++++++++++--------- internal/plugin/adventure_scheduler_test.go | 145 ++++++++++++++++++++ 2 files changed, 214 insertions(+), 74 deletions(-) create mode 100644 internal/plugin/adventure_scheduler_test.go diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index a8e043f..06acabb 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -396,7 +396,6 @@ func (p *AdventurePlugin) midnightTicker() { } func (p *AdventurePlugin) midnightReset() error { - // Send idle shame DMs to players who didn't act chars, err := loadAllAdvCharacters() if err != nil { return fmt.Errorf("load chars: %w", err) @@ -405,93 +404,89 @@ func (p *AdventurePlugin) midnightReset() error { today := time.Now().UTC().Format("2006-01-02") yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + // Unified activity oracle — same source the daily report uses. Unions + // adventure_activity_log + dnd_zone_run + dnd_expedition_log, so + // background-autopilot walks, expedition extracts/completions, and any + // subsystem that logged a beat all count as "something happened on this + // player's behalf." LastActionDate alone misses the autopilot path + // (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot + // out of streak credit) — without this oracle, a player who let the + // autopilot run a full expedition gets shamed at midnight. + todayActs, _ := loadAdvDailyActivity(today) + yesterdayActs, _ := loadAdvDailyActivity(yesterday) + dmsSent := 0 for _, char := range chars { - // This runs at 00:00 of the new UTC day, closing out the day that just - // ended (= yesterday). HasActedToday() compares LastActionDate against - // time.Now()'s date (the *new* day), so DnD-side players who credited - // the closing day via LastActionDate=yesterday would read as idle and - // get their streak halved. Credit activity on the closing day directly: - // legacy counters are still non-zero here (reset happens further below), - // and LastActionDate==yesterday means they played the day we're closing. - acted := char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0 || - char.LastActionDate == today || char.LastActionDate == yesterday - if !acted { - // If the player died today or yesterday, they couldn't act — no shame, - // no streak reset. This covers both currently-dead players and players - // who were just revived at midnight (Alive already flipped to true by - // the reminder loop before midnightReset runs). - if char.LastDeathDate == today || char.LastDeathDate == yesterday { - continue - } + // Died inside the window — no shame, no streak change. Covers both + // currently-dead players and players revived at midnight (Alive + // already flipped to true by the reminder loop before this runs). + if char.LastDeathDate == today || char.LastDeathDate == yesterday { + continue + } - // An active expedition — or a turn-based fight locked open across - // midnight — counts as activity. Both track their own action flow - // (zone/harvest/combat/transit/extract) and never touch the legacy - // CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday() - // reports false. Treat them like the acted-today branch below: - // advance the streak and bail out (no idle-shame, no streak decay). - busy := false - if exp, err := getActiveExpedition(char.UserID); err != nil { - slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) - } else if exp != nil { - busy = true - } - if !busy && hasActiveCombatSession(char.UserID) { - busy = true - } - if busy { - if char.LastActionDate == yesterday || char.LastActionDate == today { - char.CurrentStreak++ - } else { - char.CurrentStreak = 1 - } - if char.CurrentStreak > char.BestStreak { - char.BestStreak = char.CurrentStreak - } - char.LastActionDate = today - _ = saveAdvCharacter(&char) - continue - } + // Player-initiated engagement — only this credits the streak. + // LastActionDate is stamped at action time by markActedToday + !rest; + // the legacy counters get bumped by the legacy CanDo... paths. + engaged := char.LastActionDate == today || char.LastActionDate == yesterday || + char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0 - // Jitter between DMs to avoid Matrix rate limits - if dmsSent > 0 { - time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) - } - dmsSent++ - - // Idle shame DM - text := renderAdvIdleShameDM(char.UserID) - if char.CurrentStreak > 0 { - oldStreak := char.CurrentStreak - char.CurrentStreak /= 2 - char.StreakDecayed = true - text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak) - if char.CurrentStreak > 0 { - text += " — not all is lost." - } - _ = saveAdvCharacter(&char) - } - if err := p.SendDM(char.UserID, text); err != nil { - slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) - } - } else { - // Update streak — LastActionDate was set at action time + if engaged { if char.LastActionDate == yesterday || char.LastActionDate == today { char.CurrentStreak++ } else { - // Gap in activity — start fresh + // Legacy-only path: counters bumped but LastActionDate stale. + // Without this fall-through the streak would reset to 1 every + // night even with continuous play. char.CurrentStreak = 1 } if char.CurrentStreak > char.BestStreak { char.BestStreak = char.CurrentStreak } - // Restamp to today (mirrors the busy branch above). A purely-legacy - // actor whose action path bumps CombatActionsUsed/HarvestActionsUsed - // but never stamps LastActionDate lands here with a stale date; without - // this its streak would reset to 1 every night even with continuous play. char.LastActionDate = today _ = saveAdvCharacter(&char) + continue + } + + // Activity happened on the player's behalf without an explicit tap — + // autopilot walked rooms, expedition log gained beats, a fight session + // is still locked open. Hold the streak: no bump, no shame, no decay. + // The player engaged earlier to kick this off; autopilot is a feature, + // not a way to game streaks, and absence of taps shouldn't strip + // progress they earned through real play. + if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 { + continue + } + // Safety net for live state the activity logs don't reflect yet + // (e.g. an active expedition that's been quiet today, or a combat + // session locked open across midnight without a log append). + if exp, err := getActiveExpedition(char.UserID); err != nil { + slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err) + } else if exp != nil { + continue + } + if hasActiveCombatSession(char.UserID) { + continue + } + + // Truly idle — shame DM + streak halve. + if dmsSent > 0 { + time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) + } + dmsSent++ + + text := renderAdvIdleShameDM(char.UserID) + if char.CurrentStreak > 0 { + oldStreak := char.CurrentStreak + char.CurrentStreak /= 2 + char.StreakDecayed = true + text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak) + if char.CurrentStreak > 0 { + text += " — not all is lost." + } + _ = saveAdvCharacter(&char) + } + if err := p.SendDM(char.UserID, text); err != nil { + slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) } } diff --git a/internal/plugin/adventure_scheduler_test.go b/internal/plugin/adventure_scheduler_test.go new file mode 100644 index 0000000..fa88dde --- /dev/null +++ b/internal/plugin/adventure_scheduler_test.go @@ -0,0 +1,145 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// TestMidnightReset_Branching exercises the three idle-reaper branches: +// - engaged: LastActionDate stamped today/yesterday → streak bumps +// - activity-without-tap: autopilot/background activity logged today but +// no LastActionDate stamp → streak holds, no shame DM, no decay +// - truly idle: nothing anywhere → streak halves, StreakDecayed=true +// +// Regression guard for the autopilot bug: a player who let the background +// auto-run walk an expedition all day used to get shamed at midnight because +// markActedToday is gated on !compact at dnd_zone_cmd.go. +func TestMidnightReset_Branching(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + today := time.Now().UTC().Format("2006-01-02") + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + + mk := func(uid, lastAction string, streak int) id.UserID { + u := id.UserID(uid) + if err := createAdvCharacter(u, uid); err != nil { + t.Fatalf("createAdvCharacter %s: %v", uid, err) + } + c, err := loadAdvCharacter(u) + if err != nil { + t.Fatalf("load %s: %v", uid, err) + } + c.LastActionDate = lastAction + c.CurrentStreak = streak + c.BestStreak = streak + if err := saveAdvCharacter(c); err != nil { + t.Fatalf("save %s: %v", uid, err) + } + return u + } + + engaged := mk("@engaged:example", yesterday, 5) + autopilot := mk("@autopilot:example", "2020-01-01", 7) + idle := mk("@idle:example", "2020-01-01", 8) + + // Simulate autopilot activity: a legacy activity-log row dated today. + // loadAdvDailyActivity unions this in, so the reaper sees the player as + // "had activity" without their LastActionDate being current. + if _, err := db.Get().Exec( + `INSERT INTO adventure_activity_log + (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key, logged_at) + VALUES (?, 'zone', 'Test Zone', 'in_progress', 0, 0, '', CURRENT_TIMESTAMP)`, + string(autopilot), + ); err != nil { + t.Fatalf("insert activity row: %v", err) + } + + p := &AdventurePlugin{} + if err := p.midnightReset(); err != nil { + t.Fatalf("midnightReset: %v", err) + } + + // Engaged → streak bumped, LastActionDate restamped to today, no decay. + if c, _ := loadAdvCharacter(engaged); c != nil { + if c.CurrentStreak != 6 { + t.Errorf("engaged: streak = %d, want 6", c.CurrentStreak) + } + if c.LastActionDate != today { + t.Errorf("engaged: LastActionDate = %q, want %q", c.LastActionDate, today) + } + if c.StreakDecayed { + t.Error("engaged: StreakDecayed = true, want false") + } + } + + // Autopilot → hold. Streak unchanged, LastActionDate stays stale, no decay. + if c, _ := loadAdvCharacter(autopilot); c != nil { + if c.CurrentStreak != 7 { + t.Errorf("autopilot: streak = %d, want 7 (held)", c.CurrentStreak) + } + if c.StreakDecayed { + t.Error("autopilot: StreakDecayed = true, want false (shamed by mistake)") + } + if c.LastActionDate == today { + t.Errorf("autopilot: LastActionDate restamped to today — autopilot must not credit streak via LastActionDate") + } + } + + // Idle → shame DM (no-op without Matrix client) + streak halved + decay flagged. + if c, _ := loadAdvCharacter(idle); c != nil { + if c.CurrentStreak != 4 { + t.Errorf("idle: streak = %d, want 4 (halved from 8)", c.CurrentStreak) + } + if !c.StreakDecayed { + t.Error("idle: StreakDecayed = false, want true") + } + } +} + +// TestMidnightReset_DeathWindowSkips: a player who died today/yesterday is +// left untouched — no shame, no streak change, no decay flag. +func TestMidnightReset_DeathWindowSkips(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + today := time.Now().UTC().Format("2006-01-02") + + u := id.UserID("@dead:example") + if err := createAdvCharacter(u, "dead"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + c, _ := loadAdvCharacter(u) + c.LastActionDate = "2020-01-01" + c.CurrentStreak = 10 + c.BestStreak = 10 + c.LastDeathDate = today + if err := saveAdvCharacter(c); err != nil { + t.Fatalf("save: %v", err) + } + + p := &AdventurePlugin{} + if err := p.midnightReset(); err != nil { + t.Fatalf("midnightReset: %v", err) + } + + got, _ := loadAdvCharacter(u) + if got.CurrentStreak != 10 { + t.Errorf("dead: streak = %d, want 10 (untouched)", got.CurrentStreak) + } + if got.StreakDecayed { + t.Error("dead: StreakDecayed = true, want false") + } +}