diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index ac6bd45..ee15dcc 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -96,12 +96,20 @@ When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expe ### D4 — DM volume + day surfaces The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`). -**Work:** -- One **end-of-day DM** per autopilot night-camp, bundling: rooms walked, fights resolved, loot, camp pitched, day++ event. Replaces today's per-room auto-walk DM in compact mode. -- One **morning re-engagement DM** when the player first opens the channel after a day-rollover (anchored to user activity, not UTC clock — drops the 06:00 wakeup if the player isn't around). -- Boss kill + run-complete still get their own DM (the climax beat, not bundled). -- TwinBee voice: first-person only (`feedback_twinbee_voice`). -- No trailing recaps (`feedback_skip_recaps`). + +**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules: +- Fork / death / run-complete still DM the walk stream — interactive + climax beats. +- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block. +- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped). +- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself. +- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent. +Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`. + +**D4-b (pending):** morning re-engagement DM anchored to user activity, not 06:00 UTC. Drop the wakeup ping when the player is idle; post the morning DM lazily on the next inbound message after a rollover. + +**Remaining work:** +- D4-b: activity-anchored morning DM (see above). +- TwinBee voice + no-recap copy pass on the digest once the shape settles. **Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency). diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index e335018..b1870d3 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -442,6 +442,34 @@ func appendExpeditionLog(expID string, day int, entryType, summary, flavor strin return err } +// dayExpeditionLog returns every log entry recorded against the given +// (expedition, day) pair, oldest first. Used by the D4-a end-of-day +// digest to bundle a single rollup DM at night-camp pitch. +func dayExpeditionLog(expID string, day int) ([]ExpeditionEntry, error) { + rows, err := db.Get().Query(` + SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor + FROM dnd_expedition_log + WHERE expedition_id = ? + AND day = ? + ORDER BY entry_id`, expID, day) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ExpeditionEntry + for rows.Next() { + var e ExpeditionEntry + if err := rows.Scan( + &e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp, + &e.Type, &e.Summary, &e.Flavor, + ); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + // recentExpeditionLog returns the last `limit` entries, newest first. func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) { if limit <= 0 { diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go index 4faab9e..a0abfc8 100644 --- a/internal/plugin/expedition_autocamp.go +++ b/internal/plugin/expedition_autocamp.go @@ -154,8 +154,10 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { // maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches // when the decision says to. Returns the player-facing camp block (or -// "" if no camp was pitched) so the autorun DM can include it. -func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string { +// "" if no camp was pitched), along with the decision and an ok flag. +// D4-a uses the Night bit on the decision to switch tryAutoRun's DM +// rendering into end-of-day digest mode. +func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) (string, autoCampDecision, bool) { uid := id.UserID(exp.UserID) var run *DungeonRun @@ -195,14 +197,14 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string { } d, ok := decideAutopilotCamp(in) if !ok { - return "" + return "", autoCampDecision{}, false } block, err := p.pitchAutopilotCamp(exp, d) if err != nil { slog.Warn("autopilot camp: pitch failed", "expedition", exp.ID, "kind", d.Kind, "err", err) - return "" + return "", autoCampDecision{}, false } - return block + return block, d, true } // pitchAutopilotCamp performs the same state mutations as the player @@ -346,14 +348,14 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav // time has elapsed since the last briefing on an event-anchored // expedition, the pitch carries Night=true and runs the burn/drift // alongside the rest. -func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string { +func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) (string, autoCampDecision, bool) { if exp == nil || exp.Status != ExpeditionStatusActive { - return "" + return "", autoCampDecision{}, false } if exp.Camp != nil && exp.Camp.Active { // Already resting — nothing to pitch. The dwell window will pass // and the next autorun tick will retry the boss engagement. - return "" + return "", autoCampDecision{}, false } kind := CampTypeStandard if exp.Supplies.Current < campSupplyCost[kind] { @@ -363,7 +365,7 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string { // No SU even for Rough — autopilot can't help here. The walk's // preflight will surface low-SU on the next tick if the player // doesn't extract. - return "" + return "", autoCampDecision{}, false } night := false if isEventAnchored(exp) { @@ -383,9 +385,9 @@ func (p *AdventurePlugin) pitchBossSafetyCamp(exp *Expedition) string { block, err := p.pitchAutopilotCamp(exp, d) if err != nil { slog.Warn("autopilot boss-safety camp: pitch failed", "expedition", exp.ID, "kind", kind, "err", err) - return "" + return "", autoCampDecision{}, false } - return block + return block, d, true } // breakAutoCampIfDue tears down an auto-pitched camp once it has dwelled diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index 63adee3..fc165de 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -1,6 +1,7 @@ package plugin import ( + "fmt" "log/slog" "strings" "time" @@ -20,13 +21,15 @@ import ( // quiet window, or on a very-fresh expedition (give them a beat). // • Walk up to autoRunRoomCap rooms per tick. Smaller than foreground's // cap so background DMs stay digestible. -// • DM-suppression rules: -// - 0 rooms walked → silent (still stuck at a fork / blocked / etc.; -// the player already knows, no point spamming). -// - rooms > 0 with stopOK (hit room cap) → silent; we'll keep walking -// on the next tick. Cadence handles pacing; no "stretch complete" -// footer churn. -// - Any other reason with rooms > 0 → DM the bundled walk. +// • DM-suppression rules (D4-a — long expedition bundling): +// - Surface ONLY for: fork (player decision), death, run-complete, +// boss-safety camp pitch (explicit hold), or a Night=true camp +// pitch (end-of-day digest). +// - Everything else — uneventful walks, preflight pauses, harvest +// interrupts, mid-day rest camps — goes silent. The accumulated +// day reads as one EoD digest DM when the autopilot night-camps. +// - Each successful background walk logs a `walk` entry so the EoD +// digest can count rooms walked without persisting raw narration. // • Idempotency: CAS-claim last_autorun_at before doing any work. A // double-fire on the same expedition is a no-op. @@ -172,70 +175,121 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error { // "no expedition" / "no run" — race with abandon/extract. Silent. return nil } + + // D4-a — drop a `walk` log entry per successful background walk so + // the EoD digest can count rooms walked from structured logs without + // persisting the raw stream narration we used to DM. + if r.rooms > 0 { + _ = appendExpeditionLog(e.ID, e.CurrentDay, "walk", + fmt.Sprintf("auto-walk: %d room(s)", r.rooms), "") + } + // Long-expedition D2-a — post-walk camp scheduler. After the walk // settles, see if the autopilot should pitch a rest camp (HP low) // or a base-camp waypoint (region boss just cleared). The walk's // own preflight handles low-SU pauses; the scheduler stays out of // fork/combat/death/complete branches by checking r.reason. campBlock := "" + var campDecision autoCampDecision + campPitched := false if r.reason == stopBossSafety { // D3 — the boss-engage gate tripped. Force-pitch a rest camp // regardless of decideAutopilotCamp's normal HP threshold and // its RoomBoss block. Next tick past dwell retries the boss. if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && fresh.Status == ExpeditionStatusActive { - campBlock = p.pitchBossSafetyCamp(fresh) + campBlock, campDecision, campPitched = p.pitchBossSafetyCamp(fresh) } } else if r.reason != stopEnded && r.reason != stopComplete && r.reason != stopBlocked && r.reason != stopFork { if fresh, ferr := getExpedition(e.ID); ferr == nil && fresh != nil && fresh.Status == ExpeditionStatusActive { - campBlock = p.maybeAutoCamp(fresh) + campBlock, campDecision, campPitched = p.maybeAutoCamp(fresh) } } - if campBlock != "" { - r.finalMsg += campBlock + _ = autoCampBroken // hint reserved for downstream digest tweaks + _ = campPitched // surfaced via campBlock != ""; kept for readability + + // D4-a DM dispatch. The old per-tick auto-walk DM is retired in compact + // mode: a Night-camp pitch flushes the accumulated day as a digest; + // every other quiet path stays silent until something interactive fires. + if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok { + if err := p.SendDM(uid, body); err != nil { + slog.Warn("expedition: autorun DM", "user", uid, "err", err) + } } - _ = autoCampBroken // reserved for D2-b end-of-day DM bundling // Emergence seam: a run-complete reached by the background ticker is // still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge. // Deferred until after the run-summary DM below so the "animal in your // house" prompt lands after the summary, not ahead of it. - // - // DM rule: surface anytime the regular suppression says to, OR - // whenever the autopilot pitched a camp this tick — a camp event - // is always worth a DM, even if the walk itself was quiet. - if shouldDMAutoRun(r) || campBlock != "" { - body := renderAutoRunDM(r) - if err := p.SendDM(uid, body); err != nil { - slog.Warn("expedition: autorun DM", "user", uid, "err", err) - } - } if r.reason == stopComplete { p.maybeRollPetArrivalOnEmerge(uid) } return nil } -// shouldDMAutoRun applies the background suppression rules. See the -// file-top design block for the rationale. -func shouldDMAutoRun(r autopilotWalkResult) bool { - if r.rooms == 0 { - return false +// buildAutoRunDM applies D4-a's surface rules and returns the DM body to +// send. ok=false means the tick is silent. Inputs: +// - expID: the expedition row id (for the EoD digest log fetch). +// - r: walk result, including reason + accumulated stream. +// - camp: rendered camp block from maybeAutoCamp / pitchBossSafetyCamp, +// or "" when no camp was pitched this tick. +// - dec: the camp decision; dec.Night is the trigger for the EoD +// digest variant. Zero-value when no pitch happened. +// +// 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). +// - Boss-safety camp pitched → short hold notice + camp +// block; walk stream dropped (compact bail was deliberate). +// - Anything else → silent. +func buildAutoRunDM(expID string, r autopilotWalkResult, camp string, dec autoCampDecision) (string, bool) { + switch r.reason { + case stopFork, stopEnded, stopComplete: + body := renderAutoRunWalkDM(r) + if camp != "" { + body += camp + } + return body, true } - if r.reason == stopOK { - // Hit the per-tick room cap. Next tick will continue; no need to - // post a "stretch complete" filler DM. - return false + if camp == "" { + return "", false } - return true + 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 + } + if dec.Reason == "boss-safety hold — resting before re-engaging" { + return "⏸ *Holding before the boss — pitching a rest camp.*\n" + camp, true + } + // Non-night auto-camp (mid-day rest / base camp waypoint). Surface a + // short notice so the player can see the dungeon's decision; full + // digest waits for the night pitch. + return camp, true } -// renderAutoRunDM bundles the staged walk narration into a single DM. -// Background can't pace via streamFlow, so we concatenate phases with a -// blank line between each beat and tack on the final message. -func renderAutoRunDM(r autopilotWalkResult) string { +// renderAutoRunWalkDM is the legacy concat-the-stream renderer, kept for +// the surfaces D4-a still DMs (fork / death / run-complete). +func renderAutoRunWalkDM(r autopilotWalkResult) string { var b strings.Builder b.WriteString("🚶 *Auto-walk*\n\n") for _, s := range r.stream { diff --git a/internal/plugin/expedition_autorun_digest.go b/internal/plugin/expedition_autorun_digest.go new file mode 100644 index 0000000..2496ab5 --- /dev/null +++ b/internal/plugin/expedition_autorun_digest.go @@ -0,0 +1,121 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" +) + +// Long-expedition D4-a — end-of-day digest renderer. +// +// When the autopilot pitches a Night=true camp the dungeon has effectively +// closed the day. Instead of dripping per-tick auto-walk DMs through the +// player's channel, D4-a suppresses those mid-day surfaces and rolls them +// into a single rollup posted alongside the night-camp block. +// +// Data source: dnd_expedition_log. The autorun ticker now writes a `walk` +// entry per successful background walk; combined with the existing +// harvest / interrupt / threat / milestone / narrative entries this gives +// the digest enough structure to summarize the day in counts + a small +// number of headline lines, without re-rendering the full per-room +// narration that the per-tick stream used to carry. +// +// Tone: terse, TwinBee first-person (feedback_twinbee_voice), no trailing +// recap paragraph (feedback_skip_recaps). The night-camp block follows +// immediately after. + +// renderEndOfDayDigest pulls every log entry for (expID, prevDay) and +// returns a markdown rollup. Returns "" when prevDay has no entries — +// the caller should fall back to the bare camp block in that case. +func renderEndOfDayDigest(expID string, prevDay int) string { + entries, err := dayExpeditionLog(expID, prevDay) + if err != nil { + slog.Warn("expedition: digest log fetch", "expedition", expID, "day", prevDay, "err", err) + return "" + } + if len(entries) == 0 { + return "" + } + var ( + walks int + harvests int + interrupts int + threatLines []string + milestoneLine []string + narrativeBits []string + ) + for _, e := range entries { + switch e.Type { + case "walk": + walks++ + case "harvest": + // Only count successful gathers — failed rolls / errors are noise. + if strings.Contains(e.Summary, "success") { + harvests++ + } + case "interrupt": + interrupts++ + case "threat": + if e.Flavor != "" { + threatLines = append(threatLines, e.Flavor) + } else if e.Summary != "" { + threatLines = append(threatLines, e.Summary) + } + case "milestone": + milestoneLine = append(milestoneLine, e.Summary) + case "narrative": + // Surface real narrative beats (camp broken / extraction prompts), + // skip housekeeping ones. + if strings.Contains(e.Summary, "autopilot broke camp") { + continue + } + narrativeBits = append(narrativeBits, e.Summary) + } + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("📜 *Day %d wraps up.*\n\n", prevDay)) + bulleted := false + if walks > 0 { + b.WriteString(fmt.Sprintf("• Walked **%d** auto-tick%s through the dark.\n", walks, pluralS(walks))) + bulleted = true + } + if interrupts > 0 { + b.WriteString(fmt.Sprintf("• Handled **%d** interrupt%s along the way.\n", interrupts, pluralS(interrupts))) + bulleted = true + } + if harvests > 0 { + b.WriteString(fmt.Sprintf("• Came back with **%d** harvest%s.\n", harvests, pluralS(harvests))) + bulleted = true + } + for _, t := range threatLines { + b.WriteString("• ") + b.WriteString(t) + b.WriteString("\n") + bulleted = true + } + for _, m := range milestoneLine { + b.WriteString("• ") + b.WriteString(m) + b.WriteString("\n") + bulleted = true + } + for _, n := range narrativeBits { + b.WriteString("• ") + b.WriteString(n) + b.WriteString("\n") + bulleted = true + } + if !bulleted { + // All entries were filtered out — fall back to the bare camp block. + return "" + } + return b.String() +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/plugin/expedition_autorun_digest_test.go b/internal/plugin/expedition_autorun_digest_test.go new file mode 100644 index 0000000..9b15fbf --- /dev/null +++ b/internal/plugin/expedition_autorun_digest_test.go @@ -0,0 +1,124 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// Long-expedition D4-a — verifies the EoD digest renderer pulls only +// the prior day's structured entries and that buildAutoRunDM's surface +// rules suppress / surface the right ticks. + +func TestRenderEndOfDayDigest_GroupsByType(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@digest:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + day := exp.CurrentDay + for _, e := range []struct{ kind, summary, flavor string }{ + {"walk", "auto-walk: 3 room(s)", ""}, + {"walk", "auto-walk: 2 room(s)", ""}, + {"harvest", "autopilot harvest oak success", ""}, + {"harvest", "autopilot harvest iron failure", ""}, // not counted + {"interrupt", "noise alarm total=1", ""}, + {"threat", "drift", "The dungeon stirs awake."}, + {"milestone", "milestone awarded: first-night", ""}, + {"narrative", "autopilot broke camp (dwell elapsed)", ""}, // filtered + {"narrative", "voluntary extraction prompt", ""}, + } { + if err := appendExpeditionLog(exp.ID, day, e.kind, e.summary, e.flavor); err != nil { + t.Fatal(err) + } + } + // Entry from a different day must NOT bleed in. + if err := appendExpeditionLog(exp.ID, day+1, "walk", "next day", ""); err != nil { + t.Fatal(err) + } + + got := renderEndOfDayDigest(exp.ID, day) + if got == "" { + t.Fatal("expected non-empty digest") + } + wantSubs := []string{ + "Walked **2**", + "Handled **1** interrupt", + "**1** harvest", + "dungeon stirs awake", + "milestone awarded: first-night", + "voluntary extraction prompt", + } + for _, s := range wantSubs { + if !strings.Contains(got, s) { + t.Errorf("digest missing %q\n--- got ---\n%s", s, got) + } + } + if strings.Contains(got, "autopilot broke camp") { + t.Errorf("digest should filter housekeeping narrative\n%s", got) + } + if strings.Contains(got, "next day") { + t.Errorf("digest leaked entry from a later day\n%s", got) + } +} + +func TestRenderEndOfDayDigest_EmptyDayReturnsEmpty(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@digest-empty:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + if got := renderEndOfDayDigest(exp.ID, exp.CurrentDay); got != "" { + t.Errorf("expected empty digest for a day with no entries, got %q", got) + } +} + +func TestBuildAutoRunDM_SuppressesQuietTick(t *testing.T) { + r := autopilotWalkResult{rooms: 3, reason: stopOK, stream: []string{"…walked…"}} + body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{}) + if ok || body != "" { + t.Errorf("quiet stopOK tick should be silent, got ok=%v body=%q", ok, body) + } +} + +func TestBuildAutoRunDM_ForkSurfaces(t *testing.T) { + r := autopilotWalkResult{rooms: 1, reason: stopFork, finalMsg: "pick a path"} + body, ok := buildAutoRunDM("expid", r, "", autoCampDecision{}) + if !ok || !strings.Contains(body, "pick a path") { + t.Errorf("fork should surface, got ok=%v body=%q", ok, body) + } +} + +func TestBuildAutoRunDM_NonNightCampSurfaces(t *testing.T) { + r := autopilotWalkResult{rooms: 2, reason: stopPreflight, finalMsg: "low hp"} + camp := "\n\n⛺ **Autopilot camp**" + body, ok := buildAutoRunDM("expid", r, camp, autoCampDecision{Kind: CampTypeRough}) + if !ok || !strings.Contains(body, "Autopilot camp") { + t.Errorf("mid-day camp pitch should surface camp block, got ok=%v body=%q", ok, body) + } + // Walk stream/final message dropped — D4-a suppresses the walk narration. + if strings.Contains(body, "low hp") { + t.Errorf("non-night camp DM should not carry walk finalMsg, got %q", body) + } +} + +func TestBuildAutoRunDM_BossSafetyHasHoldHeader(t *testing.T) { + r := autopilotWalkResult{rooms: 0, reason: stopBossSafety} + camp := "\n\n⛺ **Autopilot camp — Standard.**" + dec := autoCampDecision{Kind: CampTypeStandard, Reason: "boss-safety hold — resting before re-engaging"} + body, ok := buildAutoRunDM("expid", r, camp, dec) + if !ok || !strings.Contains(body, "Holding before the boss") { + t.Errorf("boss-safety camp should prepend hold header, got ok=%v body=%q", ok, body) + } +}