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."
This commit is contained in:
prosolis
2026-05-27 19:21:49 -07:00
parent 5a6e395805
commit aaec0ba225
4 changed files with 174 additions and 2 deletions

View File

@@ -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. - 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`. 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:** **Remaining work:**
- D4-b: activity-anchored morning DM (see above).
- TwinBee voice + no-recap copy pass on the digest once the shape settles. - 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). **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).

View File

@@ -279,6 +279,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
// ── Message Dispatch ───────────────────────────────────────────────────────── // ── Message Dispatch ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { 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) // 0. D&D layer commands (Phase 1 — work in rooms and DMs)
if p.IsCommand(ctx.Body, "setup") { if p.IsCommand(ctx.Body, "setup") {
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup")) return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))

View File

@@ -42,6 +42,14 @@ const (
nightSafetyNet = 28 * time.Hour 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 // eventAnchoredCutoff — expeditions started at or after this timestamp
// use the D2-b event-anchored day-rollover model: day++/burn/threat-drift // 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 // 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) slog.Info("expedition: briefing deferred — player in combat session", "expedition", e.ID, "user", e.UserID)
continue 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 { if err := p.deliverBriefing(e, now); err != nil {
slog.Error("expedition: briefing", "expedition", e.ID, "err", err) 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 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 // deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored
// expeditions. The autopilot's night-camp pitch owns day++/burn/threat- // expeditions. The autopilot's night-camp pitch owns day++/burn/threat-
// drift; the ticker just re-engages the player. If the autopilot has // drift; the ticker just re-engages the player. If the autopilot has

View File

@@ -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) { func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@exp-cycle-harsh:example") uid := id.UserID("@exp-cycle-harsh:example")