mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
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:
@@ -350,10 +350,12 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
|||||||
// A double-fire on the same expedition is a no-op.
|
// A double-fire on the same expedition is a no-op.
|
||||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||||
priorBriefing := e.LastBriefingAt
|
priorBriefing := e.LastBriefingAt
|
||||||
// Capture the day number before any rollover below bumps it: the
|
// Everything logged since the previous briefing is what the overnight
|
||||||
// overnight digest reports the day that just ended, not the one
|
// digest reports. Captured before the CAS below clobbers the column.
|
||||||
// starting now.
|
digestSince := e.StartDate
|
||||||
priorDay := e.CurrentDay
|
if priorBriefing != nil {
|
||||||
|
digestSince = *priorBriefing
|
||||||
|
}
|
||||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||||
res, err := db.Get().Exec(`
|
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
|
// DM (rollover happened recently) or force-fires processNightCamp
|
||||||
// itself (safety net for stalled autopilots).
|
// itself (safety net for stalled autopilots).
|
||||||
if isEventAnchored(e) {
|
if isEventAnchored(e) {
|
||||||
return p.deliverBriefingEventAnchored(e, priorBriefing, priorDay)
|
return p.deliverBriefingEventAnchored(e, priorBriefing, digestSince)
|
||||||
}
|
}
|
||||||
|
|
||||||
burn, err := p.nightRolloverBurn(e)
|
burn, err := p.nightRolloverBurn(e)
|
||||||
@@ -405,8 +407,8 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
// The single daily message: fold in what the now-silent recap, night
|
// The single daily message: fold in what the now-silent recap, night
|
||||||
// check and ambient events recorded against the day that just ended.
|
// check and ambient events recorded since the last briefing.
|
||||||
body = appendOvernightDigest(body, e.ID, priorDay)
|
body = appendOvernightDigest(body, e.ID, digestSince)
|
||||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
body += "\n" + sl + "\n"
|
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
|
// 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.
|
// (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()
|
now := time.Now().UTC()
|
||||||
var since time.Duration
|
var since time.Duration
|
||||||
if priorBriefing != nil {
|
if priorBriefing != nil {
|
||||||
@@ -529,7 +533,7 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
body = appendOvernightDigest(body, e.ID, priorDay)
|
body = appendOvernightDigest(body, e.ID, digestSince)
|
||||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
body += "\n" + sl + "\n"
|
body += "\n" + sl + "\n"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/peteclient"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -40,6 +43,12 @@ const (
|
|||||||
// carries before it defers to the site. The cap is the whole point of
|
// 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.
|
// the change: the digest is a teaser for the feed, not a transcript.
|
||||||
digestMaxLines = 8
|
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
|
// 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
|
// digestSiteFooter appends the reader's own site link. Per-reader rather
|
||||||
// than per-expedition: a party shares a briefing body but each member's
|
// than per-expedition: a party shares a briefing body but each member's
|
||||||
// link goes to their own sheet.
|
// 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 {
|
func digestSiteFooter(uid id.UserID, body string) string {
|
||||||
|
if !peteclient.Enabled() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
return body + "\n\n🔗 _Watch it live: " + adventureWhoURL(uid) + "_"
|
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.
|
// appendOvernightDigest folds everything that happened since the previous
|
||||||
// A log read failure is non-fatal: the briefing is the player's only daily
|
// briefing into a briefing body. A log read failure is non-fatal: the briefing
|
||||||
// message now, so a missing digest block must never cost them the whole DM.
|
// is the player's only daily message now, so a missing digest block must never
|
||||||
func appendOvernightDigest(body, expID string, priorDay int) string {
|
// cost them the whole DM.
|
||||||
entries, err := dayLogEntries(expID, priorDay)
|
//
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
slog.Warn("expedition: digest entries", "expedition", expID, "err", err)
|
slog.Warn("expedition: digest entries", "expedition", expID, "err", err)
|
||||||
return body
|
return body
|
||||||
@@ -112,6 +136,35 @@ func appendOvernightDigest(body, expID string, priorDay int) string {
|
|||||||
return body + "\n" + digest
|
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.
|
// digestSkipTypes — log entry types the morning digest never echoes.
|
||||||
// `briefing` and `recap` are the frame itself, and the free-narration
|
// `briefing` and `recap` are the frame itself, and the free-narration
|
||||||
// types are the per-room prose the site renders in full.
|
// 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
|
// Returns "" when there is nothing worth reporting — a day with only walks
|
||||||
// and narration gets no block at all rather than an empty header.
|
// and narration gets no block at all rather than an empty header.
|
||||||
func renderOvernightDigest(entries []ExpeditionEntry) string {
|
func renderOvernightDigest(entries []ExpeditionEntry) string {
|
||||||
var walks int
|
var rooms int
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, en := range entries {
|
for _, en := range entries {
|
||||||
if en.Type == "walk" {
|
if en.Type == "walk" {
|
||||||
walks++
|
rooms += walkEntryRooms(en.Summary)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if digestSkipTypes[en.Type] {
|
if digestSkipTypes[en.Type] {
|
||||||
@@ -149,14 +202,14 @@ func renderOvernightDigest(entries []ExpeditionEntry) string {
|
|||||||
}
|
}
|
||||||
lines = append(lines, s)
|
lines = append(lines, s)
|
||||||
}
|
}
|
||||||
if walks == 0 && len(lines) == 0 {
|
if rooms == 0 && len(lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("📜 **Since yesterday**\n")
|
b.WriteString("📜 **Since yesterday**\n")
|
||||||
if walks > 0 {
|
if rooms > 0 {
|
||||||
b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(walks)))
|
b.WriteString(fmt.Sprintf("• walked %s\n", pluralRooms(rooms)))
|
||||||
}
|
}
|
||||||
shown := lines
|
shown := lines
|
||||||
overflow := 0
|
overflow := 0
|
||||||
@@ -173,6 +226,19 @@ func renderOvernightDigest(entries []ExpeditionEntry) string {
|
|||||||
return b.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.
|
// pluralRooms renders a room count with the right noun.
|
||||||
func pluralRooms(n int) string {
|
func pluralRooms(n int) string {
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import (
|
|||||||
|
|
||||||
func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) {
|
func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) {
|
||||||
entries := []ExpeditionEntry{
|
entries := []ExpeditionEntry{
|
||||||
{Type: "walk", Summary: "walked into the sump"},
|
// One `walk` entry is one autopilot tick, and a tick can cover
|
||||||
{Type: "walk", Summary: "walked into the gallery"},
|
// several rooms — the digest reports rooms, not ticks.
|
||||||
{Type: "walk", Summary: "walked into the stair"},
|
{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: "briefing", Summary: "morning briefing — 1.0 SU consumed overnight"},
|
||||||
{Type: "narrative", Summary: "the corridor bends left"},
|
{Type: "narrative", Summary: "the corridor bends left"},
|
||||||
{Type: "ambient", Summary: "ambient: pack_rat — Supplies -0.5"},
|
{Type: "ambient", Summary: "ambient: pack_rat — Supplies -0.5"},
|
||||||
@@ -28,7 +29,7 @@ func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := renderOvernightDigest(entries)
|
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)
|
t.Errorf("walks not collapsed to a count:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "ambient: pack_rat") {
|
if !strings.Contains(got, "ambient: pack_rat") {
|
||||||
@@ -213,6 +214,7 @@ func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) {
|
|||||||
uid := id.UserID("@digest-briefing:example")
|
uid := id.UserID("@digest-briefing:example")
|
||||||
defer cleanupExpeditions(uid)
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
enablePeteSeam(t)
|
||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
sink := installSink(p)
|
sink := installSink(p)
|
||||||
|
|
||||||
@@ -221,8 +223,11 @@ func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Stand in for the day that just went by silently.
|
// Stand in for the day that just went by silently. The day number is
|
||||||
if err := appendExpeditionLog(exp.ID, exp.CurrentDay, "ambient",
|
// 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 {
|
"ambient: pack_rat — Supplies -0.5", "Something nibbled the stores."); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user