Compare commits

..
2 Commits
Author SHA1 Message Date
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
prosolis ae5e10435a expedition: cut the DM drip to one message a day
Adventure's web feed now carries a live per-room run log, so the bot no
longer narrates every beat into Matrix. The three per-player DM sources
that fired on a clock collapse into the 06:00 briefing:

  - ambient events (6h cooldown, up to ~3/day)
  - the 21:00 recap
  - the autopilot's Night-camp end-of-day digest

Only the messaging goes quiet. Every mechanical effect still fires on
exactly the schedule it always did: ambient still applies its ±SU and
threat nudges, the recap still runs the night wandering check and its
threat bump, the night camp still burns supply and rolls the day. Each
now writes its outcome to the expedition log and stops there, and the
next morning's briefing reads that log back as a "Since yesterday"
block -- walks collapsed to a count, frame and narration types skipped,
capped with an explicit overflow note so a truncated digest never reads
as a complete one.

The briefing carries the reader's own /adventure/who/{token} link,
reusing the salted one-way roster token the board already publishes, so
a DM link and a board link resolve to one page and neither leaks a
Matrix handle. PETE_PUBLIC_URL overrides the default; it is deliberately
not PETE_INGEST_URL, which is Headscale-only and unreachable from a
player's phone.

N1/A6's digest-anchored mid-day event roll moved to deliverBriefing
along with the digest. An anchored roll's whole premise is firing at a
moment the player is demonstrably reading a DM, and the night camp no
longer sends one; leaving it in place would have quietly starved the
anchor for exactly the autopilot-only player it was added for.

Interrupt-driven DMs are untouched: a fork needs a human, a death and a
run-completion are terminal, and a boss-safety hold is an explicit
stop. Batching those would strand a decision behind the 8h auto-pick or
report a finished story.

Net effect for a player with an active expedition: roughly 6-10 DMs a
day down to one, plus whatever genuinely interrupts.

Tests: the recap and ambient send paths had no coverage at all, so
nothing would have caught them silently dropping their mechanics along
with their message. Adds behavioural tests via the message sink for all
three silenced paths, digest rendering, token-not-handle in the URL,
and a keep-set regression guard.
2026-07-26 20:34:42 -07:00
5 changed files with 551 additions and 47 deletions
+36 -13
View File
@@ -350,6 +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
// 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(`
@@ -373,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)
return p.deliverBriefingEventAnchored(e, priorBriefing, digestSince)
}
burn, err := p.nightRolloverBurn(e)
@@ -400,6 +406,9 @@ 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 since the last briefing.
body = appendOvernightDigest(body, e.ID, digestSince)
if sl := p.shadowBriefingLine(e); sl != "" {
body += "\n" + sl + "\n"
}
@@ -413,7 +422,13 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
body += "\n" + ml
}
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
// Only on a run still under way: an expedition that just ended does not
// want a mid-day event landing on top of the emergence.
if e.Status == ExpeditionStatusActive {
p.fireDigestEventAnchor(e)
}
// Emergence seam: a briefing-time forced extraction (starvation / abyss
// collapse) surfaces the players alive — roll pet arrival. Combat/patrol
// deaths never reach deliverBriefing (the row is already abandoned), so an
@@ -489,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) 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 {
@@ -516,6 +533,7 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
body = appendOvernightDigest(body, e.ID, digestSince)
if sl := p.shadowBriefingLine(e); sl != "" {
body += "\n" + sl + "\n"
}
@@ -529,7 +547,13 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
body += "\n" + ml
}
p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
p.fanOutExpeditionDM(e, body, p.briefingPerReader)
// N1/A6 anchor, relocated here from the retired night-camp digest DM.
// Only on a run still under way: an expedition that just ended does not
// want a mid-day event landing on top of the emergence.
if e.Status == ExpeditionStatusActive {
p.fireDigestEventAnchor(e)
}
if forced && e.Status == ExpeditionStatusAbandoned {
for _, uid := range expeditionAudience(e) {
p.maybeRollPetArrivalOnEmerge(uid)
@@ -566,9 +590,10 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
return nil
}
// E2b: night phase wandering check fires before the recap so its
// outcome is part of today's log when the recap renders.
var night *NightCheck
// E2b: night phase wandering check. Still fires on the 21:00 clock and
// still writes its own "night" log entry — processNightCheck owns that —
// which is how the outcome reaches the next morning's digest now that
// the recap itself no longer sends anything.
if e.Camp != nil && e.Camp.Active {
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
var charClass DnDClass
@@ -579,7 +604,6 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
if err := processNightCheck(e, nc); err != nil {
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
}
night = &nc
// §7.4: Feywild double-day fires an extra wandering check.
if e.ZoneID == ZoneFeywildCrossing {
if today, _ := e.RegionState["feywild_today"].(string); today == string(FeywildDistortionDouble) {
@@ -601,12 +625,11 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
return err
}
line := pickEveningRecap(e, dayEntries)
body := renderEveningRecap(e, line, dayEntries)
if night != nil {
body += "\n" + renderNightCheck(*night)
}
p.fanOutExpeditionDM(e, body, nil)
// Once-a-day cadence: the night check above has already run and already
// written its own "night" log entry, so the morning digest reports the
// outcome. Nothing is sent here. The recap entry below still lands so
// the site keeps a day boundary to render against.
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
return err
+3 -6
View File
@@ -186,13 +186,10 @@ func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
}
footer := p.applyAmbientEffect(e, ev)
body := renderAmbientDM(e, ev, line, footer)
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: ambient DM", "user", uid, "err", err)
}
}
// Once-a-day cadence: the effect above still lands on schedule, but the
// DM does not. The log entry below is what the next morning's briefing
// digest reads back, and the site renders it as it happens.
summary := fmt.Sprintf("ambient: %s", ev.Kind)
if footer != "" {
summary += " — " + footer
+11 -28
View File
@@ -304,15 +304,11 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// every other quiet path stays silent until something interactive fires.
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
p.fanOutExpeditionDM(e, body, nil)
// N1/A6 — the end-of-day digest is the primary mid-day event anchor.
// The anchor is a per-player roll against a per-player daily slot, so
// each member rolls their own; a party does not share one event.
if campDecision.Night {
for _, member := range expeditionAudience(e) {
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
}
}
}
// N1/A6's digest event anchor has moved to the 06:00 briefing along with
// the digest itself — see deliverBriefing. The anchor's whole premise is
// firing at a moment the player is demonstrably reading a DM, and the
// night camp no longer sends one.
// Emergence seam: a run-complete reached by the background ticker is
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge.
@@ -336,8 +332,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// Surface rules:
// - stopFork / stopEnded / stopComplete → render the walk DM. These
// are the interactive / climax beats and stay their own messages.
// - Night camp pitched → render the EoD digest +
// camp block. Walk stream is dropped (the digest summarizes the day).
// - Night camp pitched → silent. The once-a-day cadence
// (2026-07-26) retired the EoD digest DM: the camp writes its own
// `rest` log entry and the day it summarised is already in the log,
// so the 06:00 briefing reads the whole thing back the next morning.
// - Boss-safety camp pitched → short hold notice + camp
// block; walk stream dropped (compact bail was deliberate).
// - Anything else → silent.
@@ -354,24 +352,9 @@ func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCa
return "", false
}
if dec.Night {
// EoD digest. The camp pitch already bumped current_day in
// nightRolloverBurn, so the day-that-just-ended is CurrentDay-1.
// digest is the day rollup, then the camp block lays out the rest.
fresh, ferr := getExpedition(expID)
prevDay := 0
if ferr == nil && fresh != nil {
prevDay = fresh.CurrentDay - 1
}
digest := ""
if prevDay > 0 {
digest = renderEndOfDayDigest(expID, prevDay)
}
if digest == "" {
// No structured day yet — fall back to a thin header so the
// camp block isn't dropped on the player without context.
digest = "🌙 *The day winds down.*\n\n"
}
return digest + camp, true
// Silent: the morning briefing is the one daily message now, and it
// renders this same day out of the expedition log.
return "", false
}
if dec.Reason == "boss-safety hold — resting before re-engaging" {
return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true
+248
View File
@@ -0,0 +1,248 @@
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)
}
+253
View File
@@ -0,0 +1,253 @@
package plugin
import (
"fmt"
"strings"
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// Coverage for the once-a-day cadence (2026-07-26). The behavioural tests
// below are the point of the file: before this change nothing asserted that
// the recap and ambient paths sent a DM, so nothing would have caught them
// silently continuing to send — or, worse, silently dropping the mechanical
// effect along with the message.
func TestRenderOvernightDigest_CollapsesWalksAndSkipsFrameTypes(t *testing.T) {
entries := []ExpeditionEntry{
// 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"},
{Type: "night", Summary: "Signs of passage near camp; no encounter."},
{Type: "recap", Summary: "evening recap — 6 log entries today"},
}
got := renderOvernightDigest(entries)
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") {
t.Errorf("ambient entry missing from digest:\n%s", got)
}
if !strings.Contains(got, "Signs of passage") {
t.Errorf("night check missing from digest:\n%s", got)
}
for _, unwanted := range []string{"morning briefing", "evening recap", "corridor bends"} {
if strings.Contains(got, unwanted) {
t.Errorf("digest echoed frame/narration entry %q:\n%s", unwanted, got)
}
}
}
func TestRenderOvernightDigest_CapsAndReportsOverflow(t *testing.T) {
var entries []ExpeditionEntry
for i := 0; i < digestMaxLines+3; i++ {
entries = append(entries, ExpeditionEntry{
Type: "ambient",
Summary: fmt.Sprintf("ambient event %d", i),
})
}
got := renderOvernightDigest(entries)
if n := strings.Count(got, "ambient event"); n != digestMaxLines {
t.Errorf("digest carried %d lines, want the cap of %d:\n%s", n, digestMaxLines, got)
}
// A truncated digest must say so — otherwise it reads as the whole day.
if !strings.Contains(got, "and 3 more") {
t.Errorf("overflow not reported:\n%s", got)
}
}
func TestRenderOvernightDigest_EmptyWhenNothingNotable(t *testing.T) {
entries := []ExpeditionEntry{
{Type: "briefing", Summary: "morning briefing"},
{Type: "narrative", Summary: "dust everywhere"},
}
if got := renderOvernightDigest(entries); got != "" {
t.Errorf("want no digest block for an unremarkable day, got:\n%s", got)
}
}
func TestAdventureWhoURL_UsesRosterTokenNotHandle(t *testing.T) {
// The roster token is salted from a DB-persisted secret.
setupZoneRunTestDB(t)
uid := id.UserID("@digest-url:example")
got := adventureWhoURL(uid)
want := "/adventure/who/" + eventToken(uid, "roster")
if !strings.HasSuffix(got, want) {
t.Errorf("who URL = %q, want suffix %q", got, want)
}
if strings.Contains(got, "digest-url") {
t.Errorf("who URL leaked the Matrix handle: %q", got)
}
}
func TestAdventureWhoURL_FallsBackToFeedWithoutUser(t *testing.T) {
setupZoneRunTestDB(t)
if got := adventureWhoURL(""); got != adventureFeedURL() {
t.Errorf("empty user should fall back to the feed, got %q", got)
}
}
// TestBuildAutoRunDM_NightCampIsSilent — the autopilot's end-of-day digest was
// the last recurring second DM of the day. It goes quiet; the camp writes its
// own `rest` log entry, so the morning briefing still reports it.
func TestBuildAutoRunDM_NightCampIsSilent(t *testing.T) {
r := autopilotWalkResult{rooms: 4, reason: stopOK, stream: []string{"…walked…"}}
camp := "\n\n⛺ **Autopilot camp** — night"
body, ok := buildAutoRunDM("expid", r, camp, autoCampDecision{
Kind: CampTypeStandard, Night: true,
})
if ok || body != "" {
t.Errorf("night camp should be silent, got ok=%v body=%q", ok, body)
}
}
// The two interactive surfaces the night-camp cut must not touch.
func TestBuildAutoRunDM_KeepSetStillSurfaces(t *testing.T) {
fork := autopilotWalkResult{rooms: 1, reason: stopFork, finalMsg: "pick a path"}
if body, ok := buildAutoRunDM("expid", fork, "", autoCampDecision{}); !ok ||
!strings.Contains(body, "pick a path") {
t.Errorf("fork must still surface, got ok=%v body=%q", ok, body)
}
hold := autopilotWalkResult{rooms: 2, reason: stopBossSafety}
holdCamp := "\n\n⛺ **Rest camp**"
if body, ok := buildAutoRunDM("expid", hold, holdCamp, autoCampDecision{
Reason: "boss-safety hold — resting before re-engaging",
}); !ok || !strings.Contains(body, "Holding before the boss") {
t.Errorf("boss-safety hold must still surface, got ok=%v body=%q", ok, body)
}
}
// TestDeliverAmbient_SilentButStillLogs — the ambient event still fires and
// still records itself; it just stops DMing.
func TestDeliverAmbient_SilentButStillLogs(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@digest-ambient:example")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
sink := installSink(p)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := p.deliverAmbient(exp, exp.StartDate.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if dms := sink.dmsTo(uid); len(dms) != 0 {
t.Errorf("ambient sent %d DM(s), want 0:\n%s", len(dms), strings.Join(dms, "\n---\n"))
}
entries, _ := recentExpeditionLog(exp.ID, 10)
found := false
for _, e := range entries {
if e.Type == "ambient" {
found = true
}
}
if !found {
t.Error("ambient event fired without writing a log entry — the digest would lose it")
}
}
// TestDeliverRecap_SilentButStillRunsNightCheck — the recap's mechanical half
// (wandering check, threat bump) must survive the message going away.
func TestDeliverRecap_SilentButStillRunsNightCheck(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@digest-recap:example")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
sink := installSink(p)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: exp.StartDate}
if err := updateCamp(exp.ID, exp.Camp); err != nil {
t.Fatal(err)
}
recapAt := exp.StartDate.Add(12 * time.Hour)
if err := p.deliverRecap(exp, recapAt); err != nil {
t.Fatal(err)
}
if dms := sink.dmsTo(uid); len(dms) != 0 {
t.Errorf("recap sent %d DM(s), want 0:\n%s", len(dms), strings.Join(dms, "\n---\n"))
}
entries, _ := recentExpeditionLog(exp.ID, 10)
sawNight, sawRecap := false, false
for _, e := range entries {
switch e.Type {
case "night":
sawNight = true
case "recap":
sawRecap = true
}
}
if !sawNight {
t.Error("night wandering check did not run — the recap dropped its mechanics, not just its DM")
}
if !sawRecap {
t.Error("recap log entry missing — the site loses its day boundary")
}
}
// TestDeliverBriefing_IsTheOneDailyMessage — the payoff: one DM, carrying the
// prior day's silent activity and the reader's own site link.
func TestDeliverBriefing_CarriesDigestAndSiteLink(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@digest-briefing:example")
defer cleanupExpeditions(uid)
enablePeteSeam(t)
p := &AdventurePlugin{}
sink := installSink(p)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// 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)
}
if err := p.deliverBriefing(exp, exp.StartDate.Add(20*time.Hour)); err != nil {
t.Fatal(err)
}
dms := sink.dmsTo(uid)
if len(dms) != 1 {
t.Fatalf("briefing sent %d DM(s), want exactly 1:\n%s", len(dms), strings.Join(dms, "\n---\n"))
}
body := dms[0]
if !strings.Contains(body, "Since yesterday") {
t.Errorf("briefing missing the overnight digest block:\n%s", body)
}
if !strings.Contains(body, "ambient: pack_rat") {
t.Errorf("digest did not carry the silent ambient event:\n%s", body)
}
if !strings.Contains(body, adventureWhoURL(uid)) {
t.Errorf("briefing missing the reader's own site link:\n%s", body)
}
}