Files
gogobee/internal/plugin/expedition_digest.go
prosolis b1e6937c0c 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.
2026-07-26 21:00:12 -07:00

249 lines
9.0 KiB
Go

package plugin
// Once-a-day cadence (2026-07-26).
//
// Adventure's web feed is now the place to watch a run move minute to minute,
// so the bot no longer narrates every beat into Matrix. The three per-player
// DM sources that fired on a clock — the 06:00 briefing, the 21:00 recap, and
// the 6-hourly ambient event — collapse into a single morning message.
//
// The rule that keeps this honest: only the *messaging* goes quiet. Every
// mechanical effect still fires on exactly the schedule it always did. The
// ambient ticker still applies its ±SU nudges, the recap still runs the night
// wandering check and its threat bump, the briefing still burns supply and
// rolls the day. What changes is that ambient and recap now write their
// outcome to the expedition log and stop there; the next morning's briefing
// reads that log back and reports it.
//
// Interrupt-driven DMs are deliberately untouched. A fork needs a human, a
// death and a run-completion are terminal, and a mischief hit or a rival
// challenge is somebody else acting on you. Those still arrive when they
// happen; batching them to the next morning would either strand a decision
// behind an 8h auto-pick or report a finished story.
import (
"fmt"
"log/slog"
"os"
"strings"
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
const (
// defaultPeteSiteURL — Pete's public site. Distinct from PETE_INGEST_URL,
// which is the Headscale-only ingest endpoint and is not reachable by a
// player clicking a link in a DM.
defaultPeteSiteURL = "https://news.parodia.dev"
// digestMaxLines — how many prior-day log lines the morning digest
// 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
// trailing slash. Overridable so a dev instance can point its links at
// a local Pete instead of prod.
func peteSiteURL() string {
if v := strings.TrimRight(os.Getenv("PETE_PUBLIC_URL"), "/"); v != "" {
return v
}
return defaultPeteSiteURL
}
// adventureFeedURL is the general Adventure feed: everyone's activity.
func adventureFeedURL() string {
return peteSiteURL() + "/adventure"
}
// adventureWhoURL is the reader's own adventurer page. Keyed by the same
// salted, one-way roster token the board already publishes (see
// pete_roster.go), so a DM link and a board link resolve to one page and
// neither one leaks a Matrix handle.
func adventureWhoURL(uid id.UserID) string {
if uid == "" {
return adventureFeedURL()
}
return peteSiteURL() + "/adventure/who/" + eventToken(uid, "roster")
}
// 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) + "_"
}
// briefingPerReader is the per-reader decorator for the one daily message:
// the reader's own pet event on the front, the reader's own site link on the
// back. Both are per-member, so a party's shared briefing body still reaches
// each player personalised at both ends.
func (p *AdventurePlugin) briefingPerReader(uid id.UserID, body string) string {
return digestSiteFooter(uid, p.briefingPetPrefix(uid, body))
}
// fireDigestEventAnchor rolls N1/A6's digest-anchored mid-day event for each
// member. It used to hang off the autopilot's night-camp digest DM; that DM is
// gone, so it moved here — the briefing is now the message the player is
// demonstrably reading, which is the whole premise of an anchored roll.
//
// Still a per-player roll against a per-player daily slot: a party does not
// share one event.
func (p *AdventurePlugin) fireDigestEventAnchor(e *Expedition) {
for _, member := range expeditionAudience(e) {
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
}
}
// 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
}
digest := renderOvernightDigest(entries)
if digest == "" {
return body
}
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.
var digestSkipTypes = map[string]bool{
"briefing": true,
"recap": true,
"narrative": true,
"transit": true,
"action": true,
"journal": true,
}
// renderOvernightDigest condenses one expedition-day of log entries into the
// "here is what you missed" block that opens the morning briefing. Walks
// collapse to a count; everything notable keeps its own summary line, capped
// at digestMaxLines with an explicit overflow note so a truncated digest
// never reads as a complete one.
//
// 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 rooms int
var lines []string
for _, en := range entries {
if en.Type == "walk" {
rooms += walkEntryRooms(en.Summary)
continue
}
if digestSkipTypes[en.Type] {
continue
}
s := strings.TrimSpace(en.Summary)
if s == "" {
continue
}
lines = append(lines, s)
}
if rooms == 0 && len(lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString("📜 **Since yesterday**\n")
if rooms > 0 {
b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(rooms)))
}
shown := lines
overflow := 0
if len(shown) > digestMaxLines {
overflow = len(shown) - digestMaxLines
shown = shown[:digestMaxLines]
}
for _, l := range shown {
b.WriteString("• " + l + "\n")
}
if overflow > 0 {
b.WriteString(fmt.Sprintf("• _...and %d more, on the site._\n", overflow))
}
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 {
return "1 room"
}
return fmt.Sprintf("%d rooms", n)
}