digest: report the day the player actually played

The morning digest looked up log entries by e.CurrentDay - 1, which
assumed the day rollover happens in deliverBriefing. That only holds for
legacy UTC-anchored runs; event-anchored expeditions roll current_day at
night camp, so by 06:00 CurrentDay is already the day that is starting
and the digest reported a near-empty day. Window on the last briefing
timestamp instead, which also covers nights the autopilot never camped.

Also count real rooms out of the auto-walk summary instead of counting
ticks, and only emit the site footer when the Pete seam is on, since the
roster push is what creates the page the link points at.
This commit is contained in:
prosolis
2026-07-26 21:00:12 -07:00
parent ae5e10435a
commit b1e6937c0c
3 changed files with 100 additions and 25 deletions
+13 -9
View File
@@ -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"
}
+76 -10
View File
@@ -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 {
+11 -6
View File
@@ -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)
}