From aaec0ba22521f007509b38af2437de0a82eb3376 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 19:21:49 -0700 Subject: [PATCH] Long expeditions D4-b: activity-anchored morning briefing Event-anchored expeditions no longer pin their re-engagement DM to 06:00 UTC. The ticker skips idle players (last_activity older than today's threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires the owed briefing on the next inbound message in any room. The OnMessage hook also stamps last_activity so chat presence (not just bot commands) counts toward "the player is here." --- gogobee_long_expedition_plan.md | 3 +- internal/plugin/adventure.go | 6 ++ internal/plugin/dnd_expedition_cycle.go | 76 ++++++++++++++++ internal/plugin/dnd_expedition_cycle_test.go | 91 ++++++++++++++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index ee15dcc..4dec0a1 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -105,10 +105,9 @@ The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and k - Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent. Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`. -**D4-b (pending):** morning re-engagement DM anchored to user activity, not 06:00 UTC. Drop the wakeup ping when the player is idle; post the morning DM lazily on the next inbound message after a rollover. +**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened. **Remaining work:** -- D4-b: activity-anchored morning DM (see above). - TwinBee voice + no-recap copy pass on the digest once the shape settles. **Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency). diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 52ff1b0..a2ade25 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -279,6 +279,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil } // ── Message Dispatch ───────────────────────────────────────────────────────── func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { + // D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle + // player's event-anchored expedition, the briefing fires here on their + // next inbound message. Fast-paths to a no-op for users with no active + // expedition. + p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC()) + // 0. D&D layer commands (Phase 1 — work in rooms and DMs) if p.IsCommand(ctx.Body, "setup") { return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup")) diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index a929ca4..f4e12f2 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -42,6 +42,14 @@ const ( nightSafetyNet = 28 * time.Hour ) +// briefingIdleSkipWindow — D4-b: an event-anchored expedition skips its +// 06:00 UTC re-engagement DM when the player's last_activity is older than +// the new day's briefing threshold (i.e. they haven't moved since before +// the day rolled). The briefing then fires lazily from OnMessage on the +// next inbound message via maybeDeliverDeferredBriefing. The safety-net +// force-fire path still wins past nightSafetyNet so stalled autopilots +// never sit forever waiting on a silent player. + // eventAnchoredCutoff — expeditions started at or after this timestamp // use the D2-b event-anchored day-rollover model: day++/burn/threat-drift // fire when the autopilot (or a player !camp) pitches a night camp, and @@ -211,6 +219,24 @@ func (p *AdventurePlugin) fireExpeditionBriefings(now time.Time) { slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID) continue } + // D4-b: skip the ticker DM for event-anchored expeditions whose + // player has been idle past the new day's threshold. The safety- + // net force path (handled inside deliverBriefingEventAnchored) + // still has to run when the autopilot stalled past nightSafetyNet, + // so only skip when both the player is idle AND we're not in the + // safety-net window. + if isEventAnchored(e) && e.LastActivity.Before(threshold) { + var since time.Duration + if e.LastBriefingAt != nil { + since = now.Sub(*e.LastBriefingAt) + } else { + since = now.Sub(e.StartDate) + } + if since <= nightSafetyNet { + slog.Info("expedition: briefing deferred — player idle, awaiting next inbound", "expedition", e.ID, "user", e.UserID) + continue + } + } if err := p.deliverBriefing(e, now); err != nil { slog.Error("expedition: briefing", "expedition", e.ID, "err", err) } @@ -403,6 +429,56 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { return nil } +// maybeDeliverDeferredBriefing — D4-b lazy-delivery hook. When the 06:00 +// UTC ticker skips an event-anchored expedition because the player was +// idle, the morning DM is posted here on their next inbound message. +// Cheap fast paths (no expedition, not event-anchored, briefing already +// stamped past today's threshold) keep the per-message cost to one +// indexed row lookup. Idempotency rides on deliverBriefing's CAS. +func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.Time) { + if uid == "" { + return + } + exp, err := getActiveExpedition(uid) + if err != nil || exp == nil || !isEventAnchored(exp) { + return + } + // Stamp presence: per-D4-b, any inbound message in any room counts as + // "the player is here." The ticker's idle-skip reads last_activity to + // decide whether to suppress the 06:00 DM, so we update it on every + // message — not just bot commands. + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_activity = ? WHERE expedition_id = ?`, + now, exp.ID); err != nil { + slog.Warn("expedition: stamp activity", "user", uid, "err", err) + } + // Only lazy-deliver when a briefing is actually owed: a previous + // briefing exists (so we know the cadence is live) or the autopilot + // has rolled past day 1 without one (so a rollover happened in the + // player's absence). Day-1 inbounds shouldn't trigger a briefing + // before the first night camp has even happened. + if exp.LastBriefingAt == nil && exp.CurrentDay <= 1 { + return + } + // Don't lazy-deliver before today's 06:00 UTC threshold. The + // deliverBriefing CAS keys off the same threshold, so a pre-06:00 + // fire would double-emit when the 06:00 ticker sweep arrives. + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + if now.Before(threshold) { + return + } + if exp.LastBriefingAt != nil && !exp.LastBriefingAt.Before(threshold) { + return + } + if hasActiveCombatSession(uid) { + return + } + if err := p.deliverBriefing(exp, now); err != nil { + slog.Warn("expedition: deferred briefing", "user", uid, "err", err) + } +} + // deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored // expeditions. The autopilot's night-camp pitch owns day++/burn/threat- // drift; the ticker just re-engages the player. If the autopilot has diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index 0ea7485..e597e79 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -172,6 +172,97 @@ func TestDeliverBriefing_EventAnchoredSafetyNetForces(t *testing.T) { } } +// D4-b: the 06:00 ticker skips event-anchored expeditions whose player +// hasn't moved since before today's threshold; the briefing lands lazily +// on the next inbound message via maybeDeliverDeferredBriefing. +func TestFireBriefings_EventAnchoredIdleSkip(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-d4b-idle:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + // Synthetic now pinned past today's 06:00 UTC so the test outcome is + // independent of wall-clock time of day. + wall := time.Now().UTC() + // Synthetic now is today's 06:30 UTC — just past the ticker threshold, + // but well inside the 28h nightSafetyNet window so the safety-net + // force path doesn't kick in. + now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + idleActivity := threshold.Add(-2 * time.Hour) + priorBriefing := now.Add(-24 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET current_day = 3 WHERE expedition_id = ?`, + exp.ID); err != nil { + t.Fatal(err) + } + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, + idleActivity, priorBriefing, exp.ID); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.fireExpeditionBriefings(now) + + got, _ := getExpedition(exp.ID) + if got.LastBriefingAt == nil || !got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("idle ticker should not have stamped last_briefing_at: got %v want %v", + got.LastBriefingAt, priorBriefing) + } + + // Simulate inbound message: lazy delivery should fire now. + p.maybeDeliverDeferredBriefing(uid, now) + got, _ = getExpedition(exp.ID) + if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("deferred delivery should have stamped a fresh last_briefing_at: got %v", + got.LastBriefingAt) + } +} + +// D4-b: an active player (last_activity >= today's threshold) still gets +// the morning DM on the regular ticker — no idle skip. +func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-d4b-active:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + wall := time.Now().UTC() + now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC) + threshold := time.Date(now.Year(), now.Month(), now.Day(), + expeditionBriefingHour, 0, 0, 0, time.UTC) + activeActivity := threshold.Add(15 * time.Minute) + priorBriefing := now.Add(-24 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`, + activeActivity, priorBriefing, exp.ID); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{} + p.fireExpeditionBriefings(now) + + got, _ := getExpedition(exp.ID) + if got.LastBriefingAt == nil || got.LastBriefingAt.Equal(priorBriefing) { + t.Fatalf("active player should have received briefing: last_briefing_at=%v", + got.LastBriefingAt) + } +} + func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@exp-cycle-harsh:example")