diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 4431e17..511ac77 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -350,6 +350,10 @@ 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 threshold := time.Date(now.Year(), now.Month(), now.Day(), expeditionBriefingHour, 0, 0, 0, time.UTC) res, err := db.Get().Exec(` @@ -373,7 +377,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, priorDay) } burn, err := p.nightRolloverBurn(e) @@ -400,6 +404,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 against the day that just ended. + body = appendOvernightDigest(body, e.ID, priorDay) if sl := p.shadowBriefingLine(e); sl != "" { body += "\n" + sl + "\n" } @@ -413,7 +420,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 +502,7 @@ 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 { +func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time, priorDay int) error { now := time.Now().UTC() var since time.Duration if priorBriefing != nil { @@ -516,6 +529,7 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) + body = appendOvernightDigest(body, e.ID, priorDay) if sl := p.shadowBriefingLine(e); sl != "" { body += "\n" + sl + "\n" } @@ -529,7 +543,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 +586,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 +600,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 +621,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 diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index bc9b19f..41a8c42 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -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 diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index d043722..fffdd6f 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -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 diff --git a/internal/plugin/expedition_digest.go b/internal/plugin/expedition_digest.go new file mode 100644 index 0000000..51bc5d6 --- /dev/null +++ b/internal/plugin/expedition_digest.go @@ -0,0 +1,182 @@ +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" + + "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 +) + +// 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. +func digestSiteFooter(uid id.UserID, body string) string { + 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 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) + 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 +} + +// 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 walks int + var lines []string + for _, en := range entries { + if en.Type == "walk" { + walks++ + continue + } + if digestSkipTypes[en.Type] { + continue + } + s := strings.TrimSpace(en.Summary) + if s == "" { + continue + } + lines = append(lines, s) + } + if walks == 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))) + } + 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() +} + +// 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) +} diff --git a/internal/plugin/expedition_digest_test.go b/internal/plugin/expedition_digest_test.go new file mode 100644 index 0000000..aa77b84 --- /dev/null +++ b/internal/plugin/expedition_digest_test.go @@ -0,0 +1,248 @@ +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{ + {Type: "walk", Summary: "walked into the sump"}, + {Type: "walk", Summary: "walked into the gallery"}, + {Type: "walk", Summary: "walked into the stair"}, + {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 3 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) + + 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. + if err := appendExpeditionLog(exp.ID, exp.CurrentDay, "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) + } +}