mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
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.
249 lines
8.3 KiB
Go
249 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|