mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user