mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user