Files
gogobee/internal/plugin/expedition_autorun_digest.go
prosolis aab7a7bad0 N5/D1b: boss epilogues + TwinBee's journal reactions
- Boss epilogues: a 2-3 sentence campaign capstone per zone boss, tying
  each kill to the Hollow King arc. Appended to the boss-down moment in
  both close-out paths (finishCombatSession solo, finishPartyWin party),
  gated on the boss room (!elite) so it fires for any boss kill —
  expedition or legacy !zone — and never for elites or the arena (which
  has no ZoneID entry). Forest of Shadows is the King himself; its
  epilogue frames the fall as a shed shell, leaving the arc for the finale.
- TwinBee digest reactions: a journal page found mid-expedition writes a
  "journal" log beat; the end-of-day digest emits one first-person,
  deterministically-picked TwinBee line reacting to the day's pages. No
  net-new DM — it rides the existing night-camp digest.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:26:20 -07:00

140 lines
3.8 KiB
Go

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
forageLines []string
threatLines []string
milestoneLine []string
narrativeBits []string
journalPages int
)
for _, e := range entries {
switch e.Type {
case "walk":
walks++
case "journal":
journalPages++
case "harvest":
// Only count successful gathers — failed rolls / errors are noise.
if strings.Contains(e.Summary, "success") {
harvests++
}
case "forage":
forageLines = append(forageLines, e.Summary)
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 _, f := range forageLines {
b.WriteString("• ")
b.WriteString(f)
b.WriteString("\n")
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 react := twinBeeJournalReaction(prevDay, journalPages); react != "" {
b.WriteString("• ")
b.WriteString(react)
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"
}