diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 511ac77..71f596e 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -350,10 +350,12 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) { // A double-fire on the same expedition is a no-op. func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { priorBriefing := e.LastBriefingAt - // Capture the day number before any rollover below bumps it: the - // overnight digest reports the day that just ended, not the one - // starting now. - priorDay := e.CurrentDay + // Everything logged since the previous briefing is what the overnight + // digest reports. Captured before the CAS below clobbers the column. + digestSince := e.StartDate + if priorBriefing != nil { + digestSince = *priorBriefing + } threshold := time.Date(now.Year(), now.Month(), now.Day(), expeditionBriefingHour, 0, 0, 0, time.UTC) res, err := db.Get().Exec(` @@ -377,7 +379,7 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { // DM (rollover happened recently) or force-fires processNightCamp // itself (safety net for stalled autopilots). if isEventAnchored(e) { - return p.deliverBriefingEventAnchored(e, priorBriefing, priorDay) + return p.deliverBriefingEventAnchored(e, priorBriefing, digestSince) } burn, err := p.nightRolloverBurn(e) @@ -405,8 +407,8 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) // The single daily message: fold in what the now-silent recap, night - // check and ambient events recorded against the day that just ended. - body = appendOvernightDigest(body, e.ID, priorDay) + // check and ambient events recorded since the last briefing. + body = appendOvernightDigest(body, e.ID, digestSince) if sl := p.shadowBriefingLine(e); sl != "" { body += "\n" + sl + "\n" } @@ -502,7 +504,9 @@ func (p *AdventurePlugin) maybeDeliverDeferredBriefing(uid id.UserID, now time.T // // priorBriefing is the last_briefing_at value as of entry into deliverBriefing // (before the CAS clobbered it). nil means day-1 or genuinely never rolled. -func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time, priorDay int) error { +// digestSince is the same instant collapsed to a non-nil cutoff (start date on +// day 1) — the window the overnight digest reports. +func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time, digestSince time.Time) error { now := time.Now().UTC() var since time.Duration if priorBriefing != nil { @@ -529,7 +533,7 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) - body = appendOvernightDigest(body, e.ID, priorDay) + body = appendOvernightDigest(body, e.ID, digestSince) if sl := p.shadowBriefingLine(e); sl != "" { body += "\n" + sl + "\n" } diff --git a/internal/plugin/expedition_digest.go b/internal/plugin/expedition_digest.go index 51bc5d6..6b736a1 100644 --- a/internal/plugin/expedition_digest.go +++ b/internal/plugin/expedition_digest.go @@ -26,6 +26,9 @@ import ( "log/slog" "os" "strings" + "time" + + "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) @@ -40,6 +43,12 @@ const ( // carries before it defers to the site. The cap is the whole point of // the change: the digest is a teaser for the feed, not a transcript. digestMaxLines = 8 + + // digestScanLimit — how far back the digest reads before it gives up on + // finding the window's oldest entry. A day of autopilot ticks, ambient + // beats and room events runs to a few dozen rows; this is slack, not a + // budget. + digestScanLimit = 500 ) // peteSiteURL returns the public base URL for Pete's site, without a @@ -71,7 +80,15 @@ func adventureWhoURL(uid id.UserID) string { // digestSiteFooter appends the reader's own site link. Per-reader rather // than per-expedition: a party shares a briefing body but each member's // link goes to their own sheet. +// +// Gated on the Pete seam: peteclient.Enabled() is what starts the roster +// ticker, and the roster push is what creates the page this link points at. +// With the seam off (a dev instance, or a deploy without an ingest token) +// every daily DM would otherwise carry a guaranteed 404. func digestSiteFooter(uid id.UserID, body string) string { + if !peteclient.Enabled() { + return body + } return body + "\n\nšŸ”— _Watch it live: " + adventureWhoURL(uid) + "_" } @@ -96,11 +113,18 @@ func (p *AdventurePlugin) fireDigestEventAnchor(e *Expedition) { } } -// appendOvernightDigest folds the day that just ended into a briefing body. -// A log read failure is non-fatal: the briefing is the player's only daily -// message now, so a missing digest block must never cost them the whole DM. -func appendOvernightDigest(body, expID string, priorDay int) string { - entries, err := dayLogEntries(expID, priorDay) +// appendOvernightDigest folds everything that happened since the previous +// briefing into a briefing body. A log read failure is non-fatal: the briefing +// is the player's only daily message now, so a missing digest block must never +// cost them the whole DM. +// +// The window is a timestamp, not a day number, because the two disagree on +// every event-anchored expedition: the autopilot's night camp rolls +// current_day at camp time, so by 06:00 the day that just ended is already +// current_day-1 — and on a night the autopilot never camped, it isn't. A +// since-last-briefing window reports each entry exactly once either way. +func appendOvernightDigest(body, expID string, since time.Time) string { + entries, err := logEntriesSince(expID, since) if err != nil { slog.Warn("expedition: digest entries", "expedition", expID, "err", err) return body @@ -112,6 +136,35 @@ func appendOvernightDigest(body, expID string, priorDay int) string { return body + "\n" + digest } +// logEntriesSince returns an expedition's log entries stamped at or after +// `since`, oldest first. +// +// The cutoff is applied in Go rather than in the WHERE clause on purpose: +// dnd_expedition_log.timestamp is a DATETIME column filled by SQLite's own +// CURRENT_TIMESTAMP, and comparing it against a bound parameter goes through +// numeric affinity and does not reliably answer the question. Scanning the +// column into a time.Time does. +func logEntriesSince(expID string, since time.Time) ([]ExpeditionEntry, error) { + recent, err := recentExpeditionLog(expID, digestScanLimit) + if err != nil { + return nil, err + } + // CURRENT_TIMESTAMP has one-second resolution while the cutoff we are + // handed (a briefing stamp, or the start date on day 1) carries + // sub-second precision. Floor it, or an entry written in the same second + // as the previous briefing falls out of both windows and is never + // reported. Re-reporting inside that one second is the safe direction. + since = since.Truncate(time.Second) + out := make([]ExpeditionEntry, 0, len(recent)) + for i := len(recent) - 1; i >= 0; i-- { // recent is newest-first + if recent[i].Timestamp.Before(since) { + continue + } + out = append(out, recent[i]) + } + return out, nil +} + // digestSkipTypes — log entry types the morning digest never echoes. // `briefing` and `recap` are the frame itself, and the free-narration // types are the per-room prose the site renders in full. @@ -133,11 +186,11 @@ var digestSkipTypes = map[string]bool{ // Returns "" when there is nothing worth reporting — a day with only walks // and narration gets no block at all rather than an empty header. func renderOvernightDigest(entries []ExpeditionEntry) string { - var walks int + var rooms int var lines []string for _, en := range entries { if en.Type == "walk" { - walks++ + rooms += walkEntryRooms(en.Summary) continue } if digestSkipTypes[en.Type] { @@ -149,14 +202,14 @@ func renderOvernightDigest(entries []ExpeditionEntry) string { } lines = append(lines, s) } - if walks == 0 && len(lines) == 0 { + if rooms == 0 && len(lines) == 0 { return "" } var b strings.Builder b.WriteString("šŸ“œ **Since yesterday**\n") - if walks > 0 { - b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(walks))) + if rooms > 0 { + b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(rooms))) } shown := lines overflow := 0 @@ -173,6 +226,19 @@ func renderOvernightDigest(entries []ExpeditionEntry) string { return b.String() } +// walkEntryRooms reads the room count back out of an auto-walk log summary +// ("auto-walk: 3 room(s)"). One `walk` entry is one background tick, and a +// tick covers as many rooms as the autopilot got through — counting entries +// would report a 12-room day as a 3-room one. Unparseable summaries count as +// a single room rather than vanishing. +func walkEntryRooms(summary string) int { + var n int + if _, err := fmt.Sscanf(strings.TrimSpace(summary), "auto-walk: %d room", &n); err == nil && n > 0 { + return n + } + return 1 +} + // pluralRooms renders a room count with the right noun. func pluralRooms(n int) string { if n == 1 { diff --git a/internal/plugin/expedition_digest_test.go b/internal/plugin/expedition_digest_test.go index aa77b84..83dba74 100644 --- a/internal/plugin/expedition_digest_test.go +++ b/internal/plugin/expedition_digest_test.go @@ -17,9 +17,10 @@ import ( func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) { entries := []ExpeditionEntry{ - {Type: "walk", Summary: "walked into the sump"}, - {Type: "walk", Summary: "walked into the gallery"}, - {Type: "walk", Summary: "walked into the stair"}, + // One `walk` entry is one autopilot tick, and a tick can cover + // several rooms — the digest reports rooms, not ticks. + {Type: "walk", Summary: "auto-walk: 2 room(s)"}, + {Type: "walk", Summary: "auto-walk: 3 room(s)"}, {Type: "briefing", Summary: "morning briefing — 1.0 SU consumed overnight"}, {Type: "narrative", Summary: "the corridor bends left"}, {Type: "ambient", Summary: "ambient: pack_rat — Supplies -0.5"}, @@ -28,7 +29,7 @@ func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) { } got := renderOvernightDigest(entries) - if !strings.Contains(got, "walked 3 rooms") { + if !strings.Contains(got, "walked 5 rooms") { t.Errorf("walks not collapsed to a count:\n%s", got) } if !strings.Contains(got, "ambient: pack_rat") { @@ -213,6 +214,7 @@ func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) { uid := id.UserID("@digest-briefing:example") defer cleanupExpeditions(uid) + enablePeteSeam(t) p := &AdventurePlugin{} sink := installSink(p) @@ -221,8 +223,11 @@ func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) { if err != nil { t.Fatal(err) } - // Stand in for the day that just went by silently. - if err := appendExpeditionLog(exp.ID, exp.CurrentDay, "ambient", + // Stand in for the day that just went by silently. The day number is + // deliberately not CurrentDay: on an event-anchored run the night camp + // rolls current_day when it pitches, so entries either side of the + // rollover carry different day numbers. The digest windows on time. + if err := appendExpeditionLog(exp.ID, exp.CurrentDay+1, "ambient", "ambient: pack_rat — Supplies -0.5", "Something nibbled the stores."); err != nil { t.Fatal(err) }